@@ -2284,9 +2338,20 @@
-
+
+
+
+
+ {{ group.label }}
+ {{ group.fileCount }} files
+
+
+
-
+
@@ -2309,7 +2374,7 @@
@@ -2509,6 +2574,11 @@
No files
+
+ 暂无结果
+
+
+
@@ -7493,6 +7563,15 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
const latestConversation = conversations.value[0];
currentConversationId.value = latestConversation.conversation_id;
currentConversation.value = latestConversation;
+ if (latestConversation.job_id) {
+ jobInfo.job_id = latestConversation.job_id;
+ jobInfo.input_dir = latestConversation.input_dir || '';
+ jobInfo.output_dir = latestConversation.output_dir || '';
+ jobInfo.working_dir = latestConversation.working_dir || '';
+ activeRnaseqScopeId.value = '';
+ window.parentJobInfo = { ...jobInfo };
+ await refreshResults(latestConversation.job_id);
+ }
try {
const msgResponse = await fetch(`/api/conversations/${latestConversation.conversation_id}/messages?limit=500`);
if (msgResponse.ok) {
@@ -8531,6 +8610,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
workers: [],
queues: [],
active_tasks: [],
+ resource_tasks: [],
summary: {
worker_count: 0,
queue_count: 0,
@@ -8632,6 +8712,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
{ stage: 'rnaseq_candidate_family', label: 'candidate', group: 'rnaseq', default_cpu: 4, max_cpu: 4 }
]);
let runtimeMonitorTimer = null;
+ let resourceMonitorTimer = null;
const runtimeWorkerCount = computed(() => Number((runtimeOverview.summary && runtimeOverview.summary.worker_count) || 0));
const runtimeActiveTaskCount = computed(() => Number((runtimeOverview.summary && runtimeOverview.summary.active_task_count) || 0));
const geneFamilyStageCpuRows = computed(() => stageCpuRows.filter((row) => row.group === 'gene_family'));
@@ -9027,6 +9108,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
description: '',
codeFiles: [],
dataFiles: [],
+ dataPaths: '',
extensionId: '',
loading: false,
trialing: false,
@@ -9042,6 +9124,16 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
statusDialogVisible: false,
statusEvents: []
});
+ const lightweightExtensions = ref([]);
+ const lightweightExtensionConversation = reactive({
+ cards: [],
+ activeOperationToken: '',
+ activeExtensionId: '',
+ pendingRevisionCardId: ''
+ });
+ const lightweightOperationPromises = new Map();
+ const lightweightConversationCardKeys = new Set();
+ const lightweightOperationStorageKey = 'bbav5_lightweight_extension_operation';
const extensionStages = ref([]);
const extensionIsPublishedSuccessful = (item) => {
if (!item) return false;
@@ -9049,7 +9141,15 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
const templateGate = String(item.template_gate_status || '').trim();
return String(item.golden_status || '') === 'passed' && ['passed', 'legacy_unchecked'].includes(templateGate);
};
- const extensionCompletedStages = computed(() => (extensionStages.value || []).filter(extensionIsPublishedSuccessful));
+ const extensionCompletedStages = computed(() => [
+ ...(extensionStages.value || []).filter(extensionIsPublishedSuccessful),
+ ...(lightweightExtensions.value || [])
+ .filter(item => String(item.status || '') === 'published')
+ .map(item => ({ ...item, lightweight: true, display_name: item.extension_id }))
+ ]);
+ const isLightweightExtensionSelected = computed(() => (lightweightExtensions.value || []).some(item => (
+ item.extension_id === extensionBuild.extensionId && String(item.status || '') === 'published'
+ )));
const extensionStageOptionLabel = (item) => {
if (!item) return '';
const name = item.display_name || item.name || item.extension_id || '拓展阶段';
@@ -9058,6 +9158,12 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
};
const selectExistingExtensionStage = (extensionId) => {
const item = (extensionStages.value || []).find(stage => stage.extension_id === extensionId);
+ const lightweight = (lightweightExtensions.value || []).find(stage => stage.extension_id === extensionId);
+ if (lightweight) {
+ extensionBuild.extensionId = lightweight.extension_id || '';
+ extensionBuild.displayName = lightweight.extension_id || extensionBuild.displayName || '';
+ return;
+ }
if (!item) {
if (!extensionId) {
extensionBuild.extensionId = '';
@@ -9967,6 +10073,18 @@ compare: [[flesh, peel]]`;
{ name: 'pop_structure', label: 'Population Structure', status: 'pending', images: [], files: [], elapsed_time: 0 },
{ name: 'pop_ld_decay', label: 'Population LD Decay', status: 'pending', images: [], files: [], elapsed_time: 0 },
{ name: 'pop_snp_density', label: 'SNP Density', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_qc', label: 'WGS QC', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_index', label: 'WGS Reference Index', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_align', label: 'WGS Alignment', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_markdup', label: 'WGS Mark Duplicates', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_bam_qc', label: 'WGS BAM QC', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_haplotype_calling', label: 'WGS Haplotype Calling', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_gvcf_combine', label: 'WGS GVCF Combine', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_joint_genotyping', label: 'WGS Joint Genotyping', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_variant_select', label: 'WGS Variant Select', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_variant_filter', label: 'WGS Variant Filter', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_impute', label: 'WGS Imputation', status: 'pending', images: [], files: [], elapsed_time: 0 },
+ { name: 'wgs_maf_missing_filter', label: 'WGS MAF / Missing Filter', status: 'pending', images: [], files: [], elapsed_time: 0 },
{ name: 'genome_prepare', label: '0️⃣ Genome Prepare', status: 'pending', images: [], files: [], elapsed_time: 0 },
{ name: 'identify', label: '1️⃣ Identify', status: 'pending', images: [], files: [], elapsed_time: 0 },
{ name: 'identify_refine', label: '1.1 Identify Refine', status: 'pending', images: [], files: [], elapsed_time: 0 },
@@ -10005,7 +10123,32 @@ compare: [[flesh, peel]]`;
]);
// 新版结果区域 - 展开的阶段
const activeStages = ref('phylogeny');
+ const activeResultGroups = ref('gene_family');
const resultStageFilter = ref('');
+ const rnaseqResultStageNames = new Set([
+ 'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_sample_qc',
+ 'rnaseq_de', 'annotation', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich',
+ 'rnaseq_fgsea', 'rnaseq_wgcna', 'rnaseq_candidate_family'
+ ]);
+ const wgsPopulationGwasStageNames = new Set([
+ 'wgs_qc', 'wgs_index', 'wgs_align', 'wgs_markdup', 'wgs_bam_qc',
+ 'wgs_haplotype_calling', 'wgs_gvcf_combine', 'wgs_joint_genotyping',
+ 'wgs_variant_select', 'wgs_variant_filter', 'wgs_impute', 'wgs_maf_missing_filter',
+ 'pop_pca', 'pop_phylogeny', 'pop_structure', 'pop_ld_decay', 'pop_snp_density',
+ 'gwas_fmt', 'gwas_kinship', 'gwas_trait', 'gwas_cov', 'gwas_mlm',
+ 'gwas_plot', 'gwas_emmax', 'gwas_emplot', 'gwas_glm'
+ ]);
+ const resultGroupNameForStage = (stageName = '') => {
+ if (rnaseqResultStageNames.has(stageName)) return 'rnaseq';
+ if (wgsPopulationGwasStageNames.has(stageName)) return 'wgs_population_gwas';
+ return 'gene_family';
+ };
+ const wgsPopulationGwasResultRank = (stageName = '') => {
+ if (stageName.startsWith('wgs_')) return 0;
+ if (stageName.startsWith('pop_')) return 1;
+ if (stageName.startsWith('gwas_')) return 2;
+ return 3;
+ };
const resultStageAliases = {
phylogeny: ['phylogeny', 'phylogeny_msa', 'msa_region_plot'],
reference_classification: ['reference_classification'],
@@ -10024,12 +10167,12 @@ compare: [[flesh, peel]]`;
if (!filter) return null;
return new Set(resultStageAliases[filter] || [filter]);
});
- const visibleStages = computed(() => stages);
const setResultStageFilter = (stageName = '') => {
const stage = stageName || '';
resultStageFilter.value = stage;
if (stage) {
activeStages.value = stage === 'subfamily_confirm' ? 'reference_classification' : stage;
+ activeResultGroups.value = resultGroupNameForStage(activeStages.value);
rightPanelTab.value = 'results';
}
};
@@ -10631,6 +10774,26 @@ compare: [[flesh, peel]]`;
return images + resultDisplayFiles(stage).length + msaFiles + stageMsaGroupCount(stage) + stageReplotGroupCount(stage) + resetCount;
};
+ const resultGroups = computed(() => {
+ const definitions = [
+ { name: 'rnaseq', label: 'RNA-seq' },
+ { name: 'gene_family', label: '基因家族' },
+ { name: 'wgs_population_gwas', label: 'WGS2GWAS(遗传变异与群体分析)' }
+ ];
+ return definitions.map(group => {
+ const groupStages = stages.filter(stage => resultGroupNameForStage(stage.name) === group.name);
+ if (group.name === 'wgs_population_gwas') {
+ groupStages.sort((a, b) => wgsPopulationGwasResultRank(a.name) - wgsPopulationGwasResultRank(b.name));
+ }
+ return {
+ ...group,
+ stages: groupStages,
+ fileCount: groupStages.reduce((total, stage) => total + resultVisibleCount(stage), 0)
+ + (group.name === 'gene_family' ? (mcscanxHtmlFiles.value || []).length : 0)
+ };
+ });
+ });
+
const treeReplotGroupTitle = (stage) => {
const count = Array.isArray(stage?.replot_groups?.tree) ? stage.replot_groups.tree.length : 0;
const sectionNo = {
@@ -11328,7 +11491,7 @@ compare: [[flesh, peel]]`;
// 更新当前会话的 job_id
if (currentConversationId.value) {
try {
- await fetch('/api/conversations/link-to-job', {
+ const linkResponse = await fetch('/api/conversations/link-to-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -11336,6 +11499,11 @@ compare: [[flesh, peel]]`;
job_id: data.job_id
})
});
+ const linkData = await linkResponse.json();
+ if (linkData.conversation_id) {
+ currentConversationId.value = linkData.conversation_id;
+ currentConversation.value = null;
+ }
console.log('[会话] 已绑定 job:', data.job_id);
await loadConversations(); // 刷新列表
} catch (e) {
@@ -11390,7 +11558,7 @@ compare: [[flesh, peel]]`;
// 更新当前会话的 job_id
if (currentConversationId.value) {
try {
- await fetch('/api/conversations/link-to-job', {
+ const linkResponse = await fetch('/api/conversations/link-to-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -11398,6 +11566,13 @@ compare: [[flesh, peel]]`;
job_id: data.job_id
})
});
+ const linkData = await linkResponse.json();
+ if (linkData.conversation_id && linkData.conversation_id !== currentConversationId.value) {
+ currentConversationId.value = linkData.conversation_id;
+ currentConversation.value = null;
+ messages.value = [];
+ agentSession.messages = messages.value;
+ }
console.log('[会话] 已绑定 job:', data.job_id);
await loadConversations();
} catch (e) {
@@ -17771,6 +17946,54 @@ compare: [[flesh, peel]]`;
const currentTaskId = ref(null);
/** In-flight /api/execute task_ids (batch may run several in parallel) */
const activePipelineTaskIds = ref([]);
+ const ACTIVE_RUNTIME_TASK_STATUSES = new Set([
+ 'queued', 'pending', 'running', 'started', 'waiting_resource', 'retry'
+ ]);
+ const currentJobRuntimeTasks = computed(() => {
+ const currentJobId = String(jobInfo.job_id || '');
+ if (!currentJobId || currentJobId === 'default_job') return [];
+ return (runtimeOverview.resource_tasks || [])
+ .filter(task => (
+ String(task.job_id || '') === currentJobId &&
+ ACTIVE_RUNTIME_TASK_STATUSES.has(String(task.status || '').toLowerCase())
+ ))
+ .sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
+ });
+ const recoveredRuntimeTask = computed(() => currentJobRuntimeTasks.value[0] || null);
+ const executionIndicatorTask = computed(() => {
+ const localTask = currentTask.value;
+ const currentJobId = String(jobInfo.job_id || '');
+ if (
+ localTask?.stage &&
+ (!localTask.job_id || String(localTask.job_id) === currentJobId)
+ ) {
+ return localTask;
+ }
+ const backendTask = recoveredRuntimeTask.value;
+ if (!backendTask) return null;
+ return {
+ id: backendTask.task_id || backendTask.id || '',
+ stage: backendTask.stage || 'analysis',
+ job_id: backendTask.job_id || '',
+ started_at: backendTask.started_at || backendTask.updated_at || ''
+ };
+ });
+ const showExecutionIndicator = computed(() => (
+ !!executionIndicatorTask.value || isBatchExecuting.value || isExecuting.value
+ ));
+ const executionIndicatorStage = computed(() => (
+ executionIndicatorTask.value?.stage || currentBatchStage.value || confirmParams.stage || 'analysis'
+ ));
+ const executionIndicatorElapsed = computed(() => {
+ if (isBatchExecuting.value || isExecuting.value) return executeElapsed.value;
+ const startedAt = Date.parse(executionIndicatorTask.value?.started_at || '');
+ if (!Number.isFinite(startedAt)) return executeElapsed.value;
+ return Math.max(0, (Date.now() - startedAt) / 1000).toFixed(1);
+ });
+ const executionIndicatorStatusText = computed(() => (
+ isBatchExecuting.value ? 'Batch / methodology queue' :
+ (recoveredRuntimeTask.value ? 'Backend task · refresh every 5s' : 'Refresh every 5s')
+ ));
const domainReviewDialogVisible = ref(false);
const domainReviewLoading = ref(false);
const domainReviewApplying = ref(false);
@@ -18140,7 +18363,8 @@ compare: [[flesh, peel]]`;
rememberGwasGlmIdentity(stageName, result);
handoffGwasGlmProducerIdentity(stageName, result);
setResultStageFilter(stageName);
- await refreshResults(null, stageName);
+ const completedJobId = typeof jobInfo !== 'undefined' ? jobInfo.job_id : null;
+ await refreshResults(completedJobId, stageName);
if (stageName === 'identify') {
let confirmed = false;
if (subfamilyPerSpeciesMode(stageParams)) {
@@ -18558,10 +18782,25 @@ compare: [[flesh, peel]]`;
// Stop:先 revoke Celery(所有在跑的 pipeline task_id),再停批量队列(与 PlantAI 一致:仅右侧 Stop)
const stopExecution = async () => {
+ if (currentTask.value?.type === 'lightweight_extension') {
+ isStopping.value = true;
+ try {
+ await stopLightweightOperation();
+ ElMessage.success('已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。');
+ } catch (err) {
+ ElMessage.error(`停止拓展分析失败:${err.message || err}`);
+ } finally {
+ isStopping.value = false;
+ }
+ return;
+ }
const idSet = new Set();
if (currentTask.value?.id) idSet.add(currentTask.value.id);
if (currentTaskId.value) idSet.add(currentTaskId.value);
(activePipelineTaskIds.value || []).forEach((id) => id && idSet.add(id));
+ currentJobRuntimeTasks.value.forEach((task) => {
+ if (task.task_id) idSet.add(task.task_id);
+ });
let stoppedBatch = false;
if (isBatchExecuting.value) {
@@ -18885,7 +19124,8 @@ compare: [[flesh, peel]]`;
// 异步任务 - 启动轮询
currentTask.value = {
id: data.task_id,
- stage: confirmParams.stage
+ stage: confirmParams.stage,
+ job_id: jobInfo.job_id
};
currentTaskId.value = data.task_id;
@@ -19023,11 +19263,15 @@ compare: [[flesh, peel]]`;
return merged;
};
const onExtensionCodeFilesChange = (event) => {
- extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, event.target.files);
+ const selected = Array.from(event.target.files || []);
+ extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, selected);
+ if (selected.length) ElMessage.success(`已选择 ${selected.length} 个原始代码文件`);
event.target.value = '';
};
const onExtensionDataFilesChange = (event) => {
- extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, event.target.files);
+ const selected = Array.from(event.target.files || []);
+ extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, selected);
+ if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`);
event.target.value = '';
};
const removeExtensionFile = (kind, file) => {
@@ -19043,7 +19287,15 @@ compare: [[flesh, peel]]`;
try {
const data = await response.json();
const detail = data.detail || data.message || data.error;
- return detail ? `${fallback || '请求失败'}:${detail}` : `${fallback || '请求失败'}${statusText ? `(HTTP ${statusText})` : ''}`;
+ let message = '';
+ if (typeof detail === 'string') {
+ message = detail;
+ } else if (Array.isArray(detail)) {
+ message = detail.map(item => item?.msg || item?.message || JSON.stringify(item)).join(';');
+ } else if (detail && typeof detail === 'object') {
+ message = detail.message || JSON.stringify(detail);
+ }
+ return message ? `${fallback || '请求失败'}:${message}` : `${fallback || '请求失败'}${statusText ? `(HTTP ${statusText})` : ''}`;
} catch (_) {
try {
const text = await response.text();
@@ -19082,7 +19334,604 @@ compare: [[flesh, peel]]`;
命令:${command}` : error;
};
- const buildExtensionStage = async () => {
+ const extensionConfirmedParameterPayload = () => {
+ const payload = {};
+ Object.entries(extensionBuild.confirmParameters || {}).forEach(([name, spec]) => {
+ if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
+ payload[name] = spec.current_value ?? spec.value ?? spec.default ?? '';
+ } else {
+ payload[name] = spec ?? '';
+ }
+ });
+ return payload;
+ };
+ const defaultExtensionPrePublishMessage = () => {
+ const name = String(extensionBuild.displayName || extensionBuild.extensionId || '').trim();
+ return name ? `执行拓展分析 ${name}` : '执行拓展分析';
+ };
+ const resetExtensionPrePublishState = () => {
+ extensionBuild.prePublishNlGate = null;
+ extensionBuild.prePublishUiGate = null;
+ extensionBuild.prePublishReady = false;
+ extensionBuild.prePublishError = '';
+ extensionBuild.postPublishUiGate = null;
+ extensionBuild.postPublishReady = false;
+ extensionBuild.postPublishError = '';
+ extensionBuild.prePublishMessage = defaultExtensionPrePublishMessage();
+ };
+ const ensureExtensionPrePublishMessage = () => {
+ if (!String(extensionBuild.prePublishMessage || '').trim()) {
+ extensionBuild.prePublishMessage = defaultExtensionPrePublishMessage();
+ }
+ return String(extensionBuild.prePublishMessage || '').trim();
+ };
+ const runExtensionPrePublishGates = async () => {
+ if (!extensionBuild.extensionId) {
+ ElMessage.warning('请先生成拓展分析阶段。');
+ return;
+ }
+ if (!jobInfo.job_id) {
+ extensionBuild.prePublishError = '当前 job_id 为空,不能运行发布前验收。';
+ ElMessage.warning(extensionBuild.prePublishError);
+ return;
+ }
+ extensionBuild.prePublishRunning = true;
+ extensionBuild.prePublishReady = false;
+ extensionBuild.prePublishError = '';
+ extensionBuild.prePublishNlGate = null;
+ extensionBuild.prePublishUiGate = null;
+ const message = ensureExtensionPrePublishMessage();
+ const parameters = extensionConfirmedParameterPayload();
+ try {
+ pushExtensionStatus('开始发布前 NL Runtime Gate...', 'gate', true);
+ const nlResponse = await extensionApiFetch(`/api/extension-stages/${encodeURIComponent(extensionBuild.extensionId)}/nl-runtime-gate-repair`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ message,
+ job_id: jobInfo.job_id,
+ input_paths: [],
+ parameters,
+ execute: true,
+ max_attempts: 20,
+ auto_publish: false
+ })
+ });
+ if (!nlResponse.ok) throw new Error(await extensionApiError(nlResponse, 'NL Runtime Gate 失败'));
+ const nlData = await nlResponse.json();
+ const nlGate = nlData.nl_runtime_gate || (nlData.extension && nlData.extension.nl_runtime_gate) || {};
+ extensionBuild.prePublishNlGate = nlGate;
+ if (String(nlGate.status || '') !== 'passed') {
+ throw new Error(`NL Runtime Gate 未通过:${nlData.last_error || nlGate.failure_type || nlGate.status || 'unknown'}`);
+ }
+ pushExtensionStatus('NL Runtime Gate 已通过,开始 UI Display Gate...', 'gate', true);
+ const uiResponse = await extensionApiFetch(`/api/extension-stages/${encodeURIComponent(extensionBuild.extensionId)}/frontend-e2e-gate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ message,
+ job_id: jobInfo.job_id,
+ input_paths: [],
+ parameters,
+ execute: true,
+ timeout_sec: 600,
+ gate_phase: 'pre_publish'
+ })
+ });
+ if (!uiResponse.ok) throw new Error(await extensionApiError(uiResponse, 'UI Display Gate 失败'));
+ const uiData = await uiResponse.json();
+ const uiGate = uiData.ui_display_gate || {};
+ extensionBuild.prePublishUiGate = uiGate;
+ if (String(uiGate.status || '') !== 'passed' || String(uiGate.mode || '') !== 'playwright') {
+ throw new Error(`UI Display Gate 未通过:${uiGate.failure_type || uiGate.status || 'unknown'};mode=${uiGate.mode || '-'}`);
+ }
+ extensionBuild.prePublishReady = true;
+ await loadExtensionStages(true);
+ pushExtensionStatus('发布前验收已全部通过,可以发布。', 'gate', true);
+ ElMessage.success('发布前验收已通过');
+ } catch (err) {
+ extensionBuild.prePublishReady = false;
+ extensionBuild.prePublishError = err.message || '发布前验收失败';
+ pushExtensionStatus(extensionBuild.prePublishError, 'error', true);
+ ElMessage.error(extensionBuild.prePublishError.split('\n')[0]);
+ } finally {
+ extensionBuild.prePublishRunning = false;
+ }
+ };
+ const openExtensionPrePublishDialog = () => {
+ resetExtensionPrePublishState();
+ extensionBuild.prePublishDialogVisible = true;
+ };
+ const lightweightOperationToken = (action) => `${action}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
+ const lightweightCardKey = (card) => `${card.card_id}:${card.status}`;
+ const addLightweightCard = (card) => {
+ const key = lightweightCardKey(card);
+ if (lightweightConversationCardKeys.has(key)) return null;
+ lightweightConversationCardKeys.add(key);
+ const next = reactive({
+ type: 'info',
+ rows: [],
+ actions: {},
+ logs: [],
+ loading: false,
+ collapsed: false,
+ ...card
+ });
+ lightweightExtensionConversation.cards.push(next);
+ return next;
+ };
+ const finishLightweightCard = (card, summary) => {
+ card.loading = false;
+ card.collapsed = true;
+ card.collapsed_summary = summary;
+ card.actions = {};
+ };
+ const lightweightErrorText = (view, fallback) => {
+ const error = view && view.error || {};
+ const phase = error.phase || view?.action || 'operation';
+ const message = error.message || fallback || '操作失败';
+ return `阶段:${phase}\n原因:${message}`;
+ };
+ const lightweightHelp = async (extensionId) => {
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/${encodeURIComponent(extensionId)}/help`);
+ if (!response.ok) throw new Error(await extensionApiError(response, '读取拓展分析说明失败'));
+ return await response.json();
+ };
+ const isLightweightExtensionId = (extensionId) => (lightweightExtensions.value || []).some(item => item.extension_id === extensionId);
+ const lightweightRunById = (runs, runId) => (runs || []).find(item => item && item.run_id === runId) || {};
+ const lightweightResumeNext = (action) => ({
+ upload: 'candidate', retry_semantic: 'candidate', confirm: 'validate', revise: 'candidate', validate: 'review',
+ review: 'publish', publish: 'list', run: 'runtime_result'
+ })[action] || '';
+ const lightweightCandidateConfirmationSummary = (card) => card.revision_summary
+ ? `✓ 已确认候选|已修订:${card.revision_summary}`
+ : '✓ 已确认候选|未作修订';
+ const lightweightParameterText = (item) => {
+ const label = item.flag || item.fact_id || '';
+ const value = item.value ?? item.default_value ?? item.default ?? '';
+ return value === '' ? label : `${label} = ${value}`;
+ };
+ const lightweightResultRows = (run) => {
+ const projection = run.projection || {};
+ const stdout = projection.stdout || {};
+ return [
+ { item: '实际命令', value: run.command || run.execution_command || '见执行日志' },
+ { item: '输出文件', value: (projection.files || []).map(item => item.path).join(';') || '(无文件输出)' },
+ { item: '标准输出', value: stdout.present ? (stdout.text || '') : '(无)' },
+ { item: '日志', value: Object.values(run.logs || {}).join(';') || '(无)' }
+ ];
+ };
+ const lightweightRows = (help) => {
+ const inputs = (help.inputs || []).filter(item => item && item.replaceable).map(item => item.path).filter(Boolean);
+ const parameters = (help.parameters || []).map(lightweightParameterText).filter(Boolean);
+ const outputFiles = ((help.outputs || {}).files || []).map(item => item.path).filter(Boolean);
+ return [
+ { item: '功能', value: help.function_summary || '-' },
+ { item: '原始命令', value: help.original_command || '-' },
+ { item: '输入文件', value: inputs.join(';') || '(无)' },
+ { item: '可改参数', value: parameters.join(';') || '(无)' },
+ { item: '输出', value: outputFiles.join(';') || (((help.outputs || {}).stdout || {}).present ? '标准输出(直接展示)' : '(无)') }
+ ];
+ };
+ const setLightweightRunning = (extensionId, operationToken) => {
+ lightweightExtensionConversation.activeExtensionId = extensionId;
+ lightweightExtensionConversation.activeOperationToken = operationToken;
+ currentTask.value = { id: operationToken, stage: 'extension_stage', type: 'lightweight_extension', job_id: jobInfo.job_id };
+ currentTaskId.value = operationToken;
+ executing.value = true;
+ isExecuting.value = true;
+ executeStartTime = Date.now();
+ executeElapsed.value = '0.0';
+ if (!executeTimer) {
+ executeTimer = setInterval(() => {
+ if (executeStartTime) executeElapsed.value = ((Date.now() - executeStartTime) / 1000).toFixed(1);
+ }, 100);
+ }
+ };
+ const clearLightweightRunning = (operationToken) => {
+ if (lightweightExtensionConversation.activeOperationToken !== operationToken) return;
+ lightweightExtensionConversation.activeOperationToken = '';
+ lightweightExtensionConversation.activeExtensionId = '';
+ if (executeTimer) {
+ clearInterval(executeTimer);
+ executeTimer = null;
+ }
+ executing.value = false;
+ isExecuting.value = false;
+ currentTask.value = null;
+ currentTaskId.value = null;
+ };
+ const rememberLightweightOperation = (extensionId, action, operationToken, cardId) => {
+ try {
+ sessionStorage.setItem(lightweightOperationStorageKey, JSON.stringify({ extensionId, action, operationToken, cardId }));
+ } catch (_) {}
+ };
+ const forgetLightweightOperation = (operationToken) => {
+ try {
+ const current = JSON.parse(sessionStorage.getItem(lightweightOperationStorageKey) || 'null');
+ if (current && current.operationToken === operationToken) sessionStorage.removeItem(lightweightOperationStorageKey);
+ } catch (_) {}
+ };
+ const pollLightweightOperation = (extensionId, operationToken, card, onComplete) => {
+ if (lightweightOperationPromises.has(operationToken)) return lightweightOperationPromises.get(operationToken);
+ const promise = new Promise((resolve) => {
+ const poll = async () => {
+ try {
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/operations/${encodeURIComponent(operationToken)}`);
+ if (!response.ok) throw new Error(await extensionApiError(response, '读取拓展分析状态失败'));
+ const view = await response.json();
+ const terminal = ['completed', 'failed'].includes(String(view.state || ''));
+ if (!terminal) {
+ setLightweightRunning(extensionId, operationToken);
+ window.setTimeout(poll, 800);
+ return;
+ }
+ clearLightweightRunning(operationToken);
+ lightweightOperationPromises.delete(operationToken);
+ forgetLightweightOperation(operationToken);
+ card.loading = false;
+ if (view.outcome === 'failed' || view.error || view.state === 'failed') {
+ card.status = 'failed';
+ card.status_label = '失败';
+ card.type = 'danger';
+ card.error = lightweightErrorText(view);
+ card.logs = view.logs || (view.error && view.error.logs) || [];
+ if (String(view?.error?.phase || '') === 'semantic_candidate') {
+ card.title = '拓展分析说明生成失败';
+ card.purpose = '原始命令、测试数据和执行事实已经保留;这里只重新生成说明,不会重新运行原始命令。';
+ card.summary = '可以直接重试,或取消后检查错误原因。';
+ card.actions = { retry_semantic: true, cancel: true };
+ }
+ resolve(view);
+ return;
+ }
+ await onComplete(view);
+ resolve(view);
+ } catch (err) {
+ clearLightweightRunning(operationToken);
+ lightweightOperationPromises.delete(operationToken);
+ forgetLightweightOperation(operationToken);
+ card.loading = false;
+ card.status = 'failed';
+ card.status_label = '失败';
+ card.type = 'danger';
+ card.error = `阶段:前端轮询\n原因:${err.message || '无法读取操作状态'}`;
+ resolve({ state: 'failed', error: { phase: 'frontend_poll', message: err.message } });
+ }
+ };
+ poll();
+ });
+ lightweightOperationPromises.set(operationToken, promise);
+ return promise;
+ };
+ const submitLightweightOperation = async (extensionId, action, endpoint, payload, card, onComplete) => {
+ if (card.loading) return null;
+ const operationToken = lightweightOperationToken(action);
+ card.loading = true;
+ card.status = 'running';
+ card.status_label = '运行中';
+ card.type = 'primary';
+ try {
+ const response = await extensionApiFetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ operation_token: operationToken, ...payload })
+ });
+ if (!response.ok) throw new Error(await extensionApiError(response, `${action} 提交失败`));
+ rememberLightweightOperation(extensionId, action, operationToken, card.card_id);
+ setLightweightRunning(extensionId, operationToken);
+ return await pollLightweightOperation(extensionId, operationToken, card, onComplete);
+ } catch (err) {
+ clearLightweightRunning(operationToken);
+ card.loading = false;
+ card.status = 'failed';
+ card.status_label = '失败';
+ card.type = 'danger';
+ card.error = `阶段:${action}\n原因:${err.message || '提交失败'}`;
+ return null;
+ }
+ };
+ const showLightweightCandidate = async (extensionId, summary = '执行入口已在隔离环境运行。请核对系统记录的事实与说明;确认后才会验证。', revisionSummary = '') => {
+ const help = await lightweightHelp(extensionId);
+ addLightweightCard({
+ card_id: `${extensionId}:candidate:${Date.now()}`,
+ status: 'pending',
+ status_label: '待确认',
+ title: '拓展分析候选核对',
+ purpose: '这张卡用于核对系统从真实执行中锁定的命令、输入、参数和输出。',
+ summary,
+ rows: lightweightRows(help),
+ extension_id: extensionId,
+ revision_summary: revisionSummary,
+ actions: { confirm: true, cancel: true, revise: true }
+ });
+ };
+ const buildLightweightExtensionStage = async () => {
+ const hasCommand = Boolean(String(extensionBuild.command || '').trim());
+ const hasCode = extensionBuild.codeFiles.length > 0;
+ const hasDataPaths = Boolean(String(extensionBuild.dataPaths || '').trim());
+ if (!extensionBuild.displayName || (!extensionBuild.dataFiles.length && !hasDataPaths) || (!hasCommand && !hasCode)) {
+ ElMessage.warning('请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。');
+ return;
+ }
+ const extensionId = String(extensionBuild.displayName).trim().toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || `extension_${Date.now()}`;
+ const token = lightweightOperationToken('upload');
+ const card = addLightweightCard({
+ card_id: `${extensionId}:upload`, status: 'running', status_label: '运行中', type: 'primary',
+ title: '正在生成拓展分析', purpose: '系统正在确定执行入口,并运行上传的代码和测试数据,提取不可修改的执行事实。',
+ summary: '原始包可复现检查中…', extension_id: extensionId
+ });
+ if (!card) return;
+ extensionBuild.loading = true;
+ try {
+ const form = new FormData();
+ form.append('extension_id', extensionId);
+ if (hasCommand) form.append('original_command', extensionBuild.command);
+ form.append('operation_token', token);
+ extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file));
+ extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file));
+ if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths);
+ const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form });
+ if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败'));
+ ElMessage.success('文件上传成功,已开始生成并验收');
+ rememberLightweightOperation(extensionId, 'upload', token, card.card_id);
+ setLightweightRunning(extensionId, token);
+ await pollLightweightOperation(extensionId, token, card, async () => {
+ finishLightweightCard(card, '✓ 执行入口已成功复现;候选信息已生成。');
+ extensionBuild.extensionId = extensionId;
+ await loadLightweightExtensionStages();
+ await showLightweightCandidate(extensionId);
+ });
+ } catch (err) {
+ clearLightweightRunning(token);
+ card.status = 'failed';
+ card.status_label = '失败';
+ card.type = 'danger';
+ card.error = `阶段:upload\n原因:${err.message || '原始包可复现检查提交失败'}`;
+ } finally {
+ extensionBuild.loading = false;
+ }
+ };
+ const confirmLightweightCandidate = async (card) => {
+ await submitLightweightOperation(card.extension_id, 'confirm', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/confirm`, {}, card, async () => {
+ finishLightweightCard(card, lightweightCandidateConfirmationSummary(card));
+ addLightweightCard({
+ card_id: `${card.extension_id}:validate`, status: 'pending', status_label: '待执行',
+ title: '开始验证', purpose: '这张卡用于用同一份测试数据执行已确认的工具,并生成可核对的结果。',
+ summary: '确认无误后点击“开始验证”。', extension_id: card.extension_id,
+ actions: { validate: true }
+ });
+ });
+ };
+ const cancelLightweightCard = (card) => {
+ if (lightweightExtensionConversation.pendingRevisionCardId === card.card_id) {
+ lightweightExtensionConversation.pendingRevisionCardId = '';
+ }
+ card.status = 'cancelled';
+ card.status_label = '已取消';
+ finishLightweightCard(card, '已取消;没有提交后续执行。');
+ };
+ const beginLightweightRevision = (card) => {
+ lightweightExtensionConversation.pendingRevisionCardId = card.card_id;
+ card.summary = '请在页面下方的主对话输入框说明需要修订的内容,然后发送。命令和真实执行事实不会被修改。';
+ };
+ const reviseLightweightCandidate = async (card, feedback) => {
+ const normalizedFeedback = String(feedback || '').trim();
+ if (!normalizedFeedback) {
+ ElMessage.warning('请写明修订意见。');
+ return;
+ }
+ return await submitLightweightOperation(card.extension_id, 'revise', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/revise`, {
+ feedback: normalizedFeedback,
+ revision_target: 'semantic'
+ }, card, async () => {
+ const summary = `已修订:${normalizedFeedback}`;
+ finishLightweightCard(card, `✓ ${summary}`);
+ await showLightweightCandidate(card.extension_id, summary, normalizedFeedback);
+ });
+ };
+ const retryLightweightSemantic = async (card) => {
+ return await submitLightweightOperation(
+ card.extension_id,
+ 'retry_semantic',
+ `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/retry-semantic`,
+ {},
+ card,
+ async () => {
+ finishLightweightCard(card, '✓ 语义说明已重新生成;执行命令未重新运行。');
+ await showLightweightCandidate(card.extension_id);
+ }
+ );
+ };
+ const validationRows = async (extensionId, runId) => {
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/${encodeURIComponent(extensionId)}/results`);
+ if (!response.ok) throw new Error(await extensionApiError(response, '读取验证结果失败'));
+ const data = await response.json();
+ return lightweightResultRows(lightweightRunById(data.validation_runs, runId));
+ };
+ const validateLightweightExtension = async (card) => {
+ await submitLightweightOperation(card.extension_id, 'validate', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/validate`, {}, card, async (view) => {
+ finishLightweightCard(card, '✓ 验证已完成;结果核对卡已生成。');
+ addLightweightCard({
+ card_id: `${card.extension_id}:result_review:${view.run_id}`, status: 'pending', status_label: '待核对',
+ title: '运行结果核对', purpose: '这张卡用于查看实际命令、输出与日志;核对无误后才允许授权发布。',
+ rows: await validationRows(card.extension_id, view.run_id), extension_id: card.extension_id,
+ actions: { review: true }
+ });
+ });
+ };
+ const reviewLightweightResult = async (card) => {
+ await submitLightweightOperation(card.extension_id, 'review', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/review`, {}, card, async () => {
+ finishLightweightCard(card, '✓ 已核对运行结果。');
+ addLightweightCard({
+ card_id: `${card.extension_id}:publish`, status: 'pending', status_label: '待授权',
+ title: '授权发布', purpose: '这张卡用于确认将已经验证的工具发布为可复用的拓展分析。',
+ summary: '确认后才会发布;不会自动发布。', extension_id: card.extension_id,
+ actions: { publish: true, cancel: true }
+ });
+ });
+ };
+ const publishLightweightExtension = async (card) => {
+ await submitLightweightOperation(card.extension_id, 'publish', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/publish`, {}, card, async () => {
+ finishLightweightCard(card, '✓ 已显式授权并发布。');
+ await loadLightweightExtensionStages();
+ });
+ };
+ const openLightweightRuntimeCard = async (selectedExtensionId = extensionBuild.extensionId) => {
+ const extensionId = selectedExtensionId;
+ if (!extensionId) return;
+ try {
+ const help = await lightweightHelp(extensionId);
+ addLightweightCard({
+ card_id: `${extensionId}:runtime:${Date.now()}`, status: 'pending', status_label: '待确认', kind: 'runtime',
+ title: '输入文件和参数', purpose: '这张卡用于确认本次新数据运行的输入文件和原始命令已有参数;确认后立即执行,不会再次弹出确认。',
+ extension_id: extensionId,
+ inputs: (help.inputs || []).filter(item => item && item.replaceable).map(item => ({ key: item.path, value: '' })),
+ parameters: (help.parameters || []).map(item => ({ key: item.fact_id, label: item.flag || item.fact_id, value: item.value || item.flag || '' })),
+ explicit_prefix: '', actions: { run: true, cancel: true }
+ });
+ } catch (err) {
+ ElMessage.error(err.message || '读取运行说明失败');
+ }
+ };
+ const runtimeInputPath = (jobId, value) => {
+ const text = String(value || '').trim();
+ if (!text || text.startsWith('/')) return text;
+ if (text.startsWith('input/') || text.startsWith('workspace/')) return text;
+ return `input/${jobId}/${text}`;
+ };
+ const runLightweightExtension = async (card) => {
+ const jobId = String(jobInfo.job_id || '').trim();
+ if (!jobId || jobId === 'default_job') {
+ ElMessage.warning('请先选择实际运行 Job。');
+ return;
+ }
+ const inputOverrides = Object.fromEntries((card.inputs || []).map(item => [item.key, runtimeInputPath(jobId, item.value)]));
+ if (Object.values(inputOverrides).some(value => !value)) {
+ ElMessage.warning('请填写每个输入文件。');
+ return;
+ }
+ const parameterOverrides = Object.fromEntries((card.parameters || []).map(item => [item.key, item.value]));
+ await submitLightweightOperation(card.extension_id, 'run', `/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/run`, {
+ run_job_id: jobId,
+ input_overrides: inputOverrides,
+ parameter_overrides: parameterOverrides,
+ explicit_prefix: String(card.explicit_prefix || '').trim() || null
+ }, card, async (view) => {
+ finishLightweightCard(card, '✓ 已确认输入与参数;本次运行已完成。');
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/${encodeURIComponent(card.extension_id)}/results`);
+ const data = response.ok ? await response.json() : {};
+ const run = lightweightRunById(data.runtime_runs, view.run_id);
+ addLightweightCard({
+ card_id: `${card.extension_id}:runtime_result:${view.run_id}`, status: 'completed', status_label: '已完成', type: 'success',
+ title: '运行结果', purpose: '这张卡展示本次新数据运行生成的文件、标准输出和日志。',
+ extension_id: card.extension_id,
+ rows: lightweightResultRows(run)
+ });
+ });
+ };
+ const resumeLightweightOperation = async () => {
+ let saved;
+ try {
+ saved = JSON.parse(sessionStorage.getItem(lightweightOperationStorageKey) || 'null');
+ } catch (_) {
+ return;
+ }
+ if (!saved || !saved.extensionId || !saved.operationToken || !saved.action) return;
+ const card = addLightweightCard({
+ card_id: saved.cardId || `${saved.extensionId}:${saved.action}`,
+ status: 'running', status_label: '运行中', type: 'primary', extension_id: saved.extensionId,
+ title: '正在恢复拓展分析状态', purpose: '页面已恢复;系统继续轮询同一个操作,不会重复提交。',
+ summary: `正在恢复 ${saved.action}…`
+ });
+ if (!card) return;
+ await pollLightweightOperation(saved.extensionId, saved.operationToken, card, async (view) => {
+ const next = lightweightResumeNext(saved.action);
+ switch (next) {
+ case 'candidate':
+ finishLightweightCard(card, '✓ 已恢复并完成;候选核对卡已生成。');
+ extensionBuild.extensionId = saved.extensionId;
+ await loadLightweightExtensionStages();
+ await showLightweightCandidate(saved.extensionId, saved.action === 'revise' ? '已恢复修订;请核对最新候选。' : undefined);
+ break;
+ case 'validate':
+ finishLightweightCard(card, '✓ 已恢复确认;可以开始验证。');
+ addLightweightCard({
+ card_id: `${saved.extensionId}:validate`, status: 'pending', status_label: '待执行',
+ title: '开始验证', purpose: '这张卡用于用同一份测试数据执行已确认的工具,并生成可核对的结果。',
+ summary: '确认无误后点击“开始验证”。', extension_id: saved.extensionId, actions: { validate: true }
+ });
+ break;
+ case 'review':
+ finishLightweightCard(card, '✓ 已恢复验证;结果核对卡已生成。');
+ addLightweightCard({
+ card_id: `${saved.extensionId}:result_review:${view.run_id}`, status: 'pending', status_label: '待核对',
+ title: '运行结果核对', purpose: '这张卡用于查看实际命令、输出与日志;核对无误后才允许授权发布。',
+ rows: await validationRows(saved.extensionId, view.run_id), extension_id: saved.extensionId, actions: { review: true }
+ });
+ break;
+ case 'publish':
+ finishLightweightCard(card, '✓ 已恢复结果核对;可以授权发布。');
+ addLightweightCard({
+ card_id: `${saved.extensionId}:publish`, status: 'pending', status_label: '待授权',
+ title: '授权发布', purpose: '这张卡用于确认将已经验证的工具发布为可复用的拓展分析。',
+ summary: '确认后才会发布;不会自动发布。', extension_id: saved.extensionId, actions: { publish: true, cancel: true }
+ });
+ break;
+ case 'list':
+ finishLightweightCard(card, '✓ 已恢复并发布。');
+ await loadLightweightExtensionStages();
+ break;
+ case 'runtime_result': {
+ finishLightweightCard(card, '✓ 已恢复运行;本次结果卡已生成。');
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/${encodeURIComponent(saved.extensionId)}/results`);
+ const data = response.ok ? await response.json() : {};
+ addLightweightCard({
+ card_id: `${saved.extensionId}:runtime_result:${view.run_id}`, status: 'completed', status_label: '已完成', type: 'success',
+ title: '运行结果', purpose: '这张卡展示本次新数据运行生成的文件、标准输出和日志。',
+ extension_id: saved.extensionId, rows: lightweightResultRows(lightweightRunById(data.runtime_runs, view.run_id))
+ });
+ break;
+ }
+ default:
+ finishLightweightCard(card, '✓ 已恢复并完成。');
+ }
+ });
+ };
+ const stopLightweightOperation = async () => {
+ const extensionId = lightweightExtensionConversation.activeExtensionId;
+ const operationToken = lightweightExtensionConversation.activeOperationToken;
+ if (!extensionId || !operationToken) return false;
+ const response = await extensionApiFetch(`/api/lightweight-extension-stages/${encodeURIComponent(extensionId)}/operations/${encodeURIComponent(operationToken)}/stop`, { method: 'POST' });
+ if (!response.ok) throw new Error(await extensionApiError(response, '停止拓展分析失败'));
+ return true;
+ };
+ const loadLightweightExtensionStages = async () => {
+ try {
+ const response = await extensionApiFetch('/api/lightweight-extension-stages/');
+ if (!response.ok) throw new Error(await extensionApiError(response, '读取轻量拓展分析列表失败'));
+ lightweightExtensions.value = (await response.json()).extensions || [];
+ } catch (err) {
+ console.warn('[lightweight extension] list failed', err);
+ }
+ };
+ const answerLightweightExtensionHelp = async (message) => {
+ const query = String(message || '');
+ const matched = (lightweightExtensions.value || []).find(item => query.includes(item.extension_id));
+ if (!matched) {
+ const response = await extensionApiFetch('/api/lightweight-extension-stages/intent', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: query })
+ });
+ const data = response.ok ? await response.json() : {};
+ await addAgentMessage('assistant', data.usage || '上传可执行代码、测试数据和原始命令,核对候选后验证并授权发布;发布后可指定输入和原命令已有参数运行。');
+ return true;
+ }
+ const help = await lightweightHelp(matched.extension_id);
+ await addAgentMessage('assistant', lightweightRows(help).map(row => `${row.item}:${row.value}`).join('\n'));
+ return true;
+ };
+ const buildExtensionStageLegacy = async () => {
if (!extensionBuild.displayName || !extensionBuild.codeFiles.length) {
ElMessage.warning('请填写名称,并上传原始代码。');
return;
@@ -19172,6 +20021,7 @@ compare: [[flesh, peel]]`;
extensionBuild.statusDialogVisible = false;
}
};
+ const buildExtensionStage = async () => buildLightweightExtensionStage();
const loadExtensionPreview = async () => {
if (!extensionBuild.extensionId) return null;
const response = await extensionApiFetch(`/api/extension-stages/${encodeURIComponent(extensionBuild.extensionId)}/preview`);
@@ -19294,7 +20144,8 @@ compare: [[flesh, peel]]`;
extensionStages.value = data.extensions || [];
if (extensionBuild.extensionId) {
const selected = extensionStages.value.find(item => item.extension_id === extensionBuild.extensionId);
- if (!selected || (!includeDrafts && !extensionIsPublishedSuccessful(selected))) extensionBuild.extensionId = '';
+ const lightweight = lightweightExtensions.value.find(item => item.extension_id === extensionBuild.extensionId);
+ if ((!selected || (!includeDrafts && !extensionIsPublishedSuccessful(selected))) && !lightweight) extensionBuild.extensionId = '';
}
if (!extensionRun.extensionId && extensionStages.value.length) {
const published = extensionStages.value.find(extensionIsPublishedSuccessful);
@@ -19460,11 +20311,15 @@ compare: [[flesh, peel]]`;
});
};
const runExtensionStageFromIntent = async (intent, userText = '') => {
- const params = intent?.params || {};
- const extensionId = params.extension_id || params.extensionId || '';
- if (!extensionId) throw new Error('没有识别到要执行的拓展分析阶段');
- const inputPaths = Array.isArray(params.input_paths) ? params.input_paths : [];
- const runParameters = { ...(params.parameters || {}) };
+ const params = intent?.params || {};
+ const extensionId = params.extension_id || params.extensionId || '';
+ if (!extensionId) throw new Error('没有识别到要执行的拓展分析阶段');
+ if (isLightweightExtensionId(extensionId)) {
+ extensionBuild.extensionId = extensionId;
+ return await openLightweightRuntimeCard(extensionId);
+ }
+ const inputPaths = Array.isArray(params.input_paths) ? params.input_paths : [];
+ const runParameters = { ...(params.parameters || {}) };
if (params.targetSpecies) runParameters.targetSpecies = params.targetSpecies;
if (params.referenceSpecies) runParameters.referenceSpecies = params.referenceSpecies;
if (params.file_roles) runParameters.file_roles = params.file_roles;
@@ -21320,6 +22175,106 @@ compare: [[flesh, peel]]`;
nlLoading.value = true;
nlStatusText.value = '正在识别你的分析意图...';
+ const revisionCard = lightweightExtensionConversation.cards.find(
+ card => card.card_id === lightweightExtensionConversation.pendingRevisionCardId
+ );
+ if (revisionCard) {
+ try {
+ nlStatusText.value = '正在按你的意见修订拓展分析说明...';
+ if (!currentConversationId.value) await createNewConversation();
+ await addAgentMessage('user', userInput);
+ lightweightExtensionConversation.pendingRevisionCardId = '';
+ const result = await reviseLightweightCandidate(revisionCard, userInput);
+ if (!result || result.state === 'failed') {
+ lightweightExtensionConversation.pendingRevisionCardId = revisionCard.card_id;
+ }
+ } finally {
+ nlInput.value = '';
+ nlLoading.value = false;
+ nlStatusText.value = '';
+ }
+ return;
+ }
+
+ const earlyLowerInput = userInput.toLowerCase();
+ const wantsIdentifyMemberReviewEarly = (
+ !userInput.includes('亚家族') &&
+ (userInput.includes('基因家族成员') || userInput.includes('家族成员') || userInput.includes('成员筛选') || userInput.includes('成员确认') || earlyLowerInput.includes('member')) &&
+ (userInput.includes('筛选') || userInput.includes('确认') || userInput.includes('修改') || userInput.includes('修订') || userInput.includes('调出') || userInput.includes('打开') || userInput.includes('弹出') || earlyLowerInput.includes('review') || earlyLowerInput.includes('filter') || earlyLowerInput.includes('edit'))
+ );
+ if (wantsIdentifyMemberReviewEarly) {
+ try {
+ if (!jobInfo.job_id || jobInfo.job_id === 'default_job') {
+ ElMessage.warning('请先选择或创建 Working Directory');
+ nlLoading.value = false;
+ return;
+ }
+ if (!currentConversationId.value) {
+ await createNewConversation();
+ }
+ addAgentMessage('user', userInput);
+ const speciesForReview = inferSpeciesCodeFromText(userInput) || (params.targetSpecies && params.targetSpecies[0]) || '';
+ const familyForReview = inferGeneFamilyFromText(userInput) || params.geneFamily || confirmParams.geneFamily || 'SPL';
+ const reviewPrefix = speciesForReview ? `${speciesForReview}.${familyForReview}` : executionPrefix('identify');
+ const opened = await maybeShowIdentifyDomainReview('identify', true, reviewPrefix);
+ addAgentMessage('assistant', opened
+ ? `已调出${speciesForReview ? speciesForReview + ' ' : ''}${familyForReview} 基因家族成员确认表。你可以按表格筛选后重建 final_members。`
+ : `${speciesForReview ? speciesForReview + ' ' : ''}${familyForReview} 基因家族成员确认表没有打开:未找到 identify 结果或成员表。`);
+ } finally {
+ nlInput.value = '';
+ nlLoading.value = false;
+ }
+ return;
+ }
+
+ // V2: RAG模式 - 文献问答
+ if (ragMode.value) {
+ try {
+ nlStatusText.value = '正在检索知识库并生成回答...';
+ addMessage('user', userInput);
+ ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
+
+ // 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址(例如 7880)
+ const data = await fetchJsonWithTimeout('/api/agent/rag', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: userInput,
+ mode: 'rag',
+ top_k: 10
+ })
+ }, 120000, '知识库检索');
+ const answer = normalizeRagAnswerForDisplay(data.success === false
+ ? (data.message || '未找到相关信息')
+ : (data.response || data.answer || '未找到相关信息'), userInput);
+ const ragSources = data.sources || data.references || [];
+
+ addMessage('assistant', answer, {
+ intent: {
+ taskType: 'faq',
+ sources: ragSources
+ }
+ });
+ ragHistory.value.push({ role: 'assistant', content: answer, time: new Date().toLocaleTimeString() });
+
+ // 滚动到底部
+ setTimeout(() => {
+ if (chatArea.value) {
+ chatArea.value.scrollTop = chatArea.value.scrollHeight;
+ }
+ }, 100);
+
+ } catch (err) {
+ ElMessage.error('RAG请求失败: ' + err.message);
+ addMessage('assistant', '抱歉,文献检索服务暂时不可用。');
+ } finally {
+ nlInput.value = '';
+ nlLoading.value = false;
+ nlStatusText.value = '';
+ }
+ return;
+ }
+
// Agent模式 - 技能执行
try {
// 检查是否有绑定的 job
@@ -21338,6 +22293,129 @@ compare: [[flesh, peel]]`;
// 添加用户消息到Agent会话
addAgentMessage('user', userInput);
+ const lowerInput = userInput.toLowerCase();
+ const dataPaths = extractDataPathsFromText(userInput);
+ const dataPath = dataPaths.join(',');
+ const pathMatch = dataPath ? [dataPath, dataPath] : null;
+ const inferredSpeciesCode = dataPaths.length > 1 ? '' : inferSpeciesCodeFromText(userInput);
+ const inferredGeneFamily = inferGeneFamilyFromText(userInput);
+ const wantsSubfamilyReviewTable = (
+ (userInput.includes('亚家族') || lowerInput.includes('subfamily')) &&
+ (userInput.includes('分类表') || userInput.includes('确认表') || userInput.includes('标签表') || userInput.includes('结果表') || lowerInput.includes('table')) &&
+ (userInput.includes('打开') || userInput.includes('调出') || userInput.includes('弹出') || userInput.includes('修订') || userInput.includes('修改') || userInput.includes('确认') || lowerInput.includes('open') || lowerInput.includes('review') || lowerInput.includes('edit'))
+ );
+ if (wantsSubfamilyReviewTable) {
+ const speciesMatch = userInput.match(/\b([A-Z][a-zA-Z0-9]{0,5})\b/);
+ const requestedSpecies = speciesMatch ? speciesMatch[1] : '';
+ const familyForReview = inferredGeneFamily || params.geneFamily || confirmParams.geneFamily || 'SPL';
+ const reviewPrefix = requestedSpecies ? `${requestedSpecies}.${familyForReview}` : '';
+ const opened = await maybeShowSubfamilyReview('reference_classification', true, reviewPrefix);
+ if (opened) {
+ addAgentMessage('assistant', `已调出${requestedSpecies ? requestedSpecies + ' ' : ''}亚家族分类确认表。你可以在弹窗中修订 confirmed_subfamily,点击“确认亚家族标签”后保存。`);
+ } else {
+ addAgentMessage('assistant', `${requestedSpecies ? requestedSpecies + ' ' : ''}亚家族分类确认表没有打开:未找到可确认的分类结果。请先确认该物种已完成 reference_classification。`);
+ }
+ nlInput.value = '';
+ nlLoading.value = false;
+ return;
+ }
+ const mentionsConfigAutofill = lowerInput.includes('config') || lowerInput.includes('config.xlsx') || userInput.includes('配置表') || userInput.includes('配置文件');
+ const mentionsSamplesAutofill = lowerInput.includes('samples') || lowerInput.includes('samples.yaml') || userInput.includes('样本表') || userInput.includes('样本配置') || userInput.includes('样本文件');
+ const wantsExtensionStage = lowerInput.includes('extension') || userInput.includes('拓展分析') || userInput.includes('扩展分析');
+ const asksExtensionHelp = wantsExtensionStage && (
+ lowerInput.includes('help') || userInput.includes('如何使用') || userInput.includes('怎么用') ||
+ userInput.includes('输入') || userInput.includes('参数') || userInput.includes('功能') || userInput.includes('帮助')
+ );
+ if (asksExtensionHelp) {
+ try {
+ await answerLightweightExtensionHelp(userInput);
+ } catch (err) {
+ await addAgentMessage('assistant', `拓展分析帮助暂时不可用:${err.message || err}`);
+ } finally {
+ nlInput.value = '';
+ nlLoading.value = false;
+ }
+ return;
+ }
+ const wantsAutofillOpen = userInput.includes('打开') || userInput.includes('调出') || userInput.includes('弹出') || userInput.includes('修订') || userInput.includes('修改') || userInput.includes('核对') || userInput.includes('检查') || lowerInput.includes('open') || lowerInput.includes('edit') || lowerInput.includes('review');
+ const wantsAutofillRegenerate = userInput.includes('自动') || userInput.includes('填写') || userInput.includes('生成') || userInput.includes('重新识别') || userInput.includes('重新检测') || lowerInput.includes('autofill') || lowerInput.includes('regenerate');
+ if (!wantsExtensionStage && mentionsConfigAutofill && (wantsAutofillOpen || wantsAutofillRegenerate)) {
+ if (pathMatch) {
+ inputPath.value = pathMatch[1];
+ await loadFromPath({ suppressAutofill: true });
+ }
+ await openAutofillDialog('config', {
+ description: userInput,
+ speciesCode: inferredSpeciesCode,
+ analysisFamily: '',
+ autoGenerate: wantsAutofillRegenerate,
+ keepCurrentRows: !wantsAutofillRegenerate,
+ });
+ addAgentMessage('assistant', wantsAutofillRegenerate
+ ? '已重新识别并生成 config.xlsx 草稿。请在弹窗中核对并修订,确认后才会写入正式 config.xlsx。'
+ : '已打开 config.xlsx 自动识别表。你可以直接修订单行,或用批量修订区筛选、预览、Apply。');
+ nlInput.value = '';
+ nlLoading.value = false;
+ return;
+ }
+ if (!wantsExtensionStage && mentionsSamplesAutofill && (wantsAutofillOpen || wantsAutofillRegenerate)) {
+ if (pathMatch) {
+ inputPath.value = pathMatch[1];
+ await loadFromPath({ suppressAutofill: true });
+ }
+ await openAutofillDialog('samples', {
+ description: userInput,
+ autoGenerate: wantsAutofillRegenerate,
+ keepCurrentRows: !wantsAutofillRegenerate,
+ });
+ addAgentMessage('assistant', wantsAutofillRegenerate
+ ? '已重新识别并生成 samples.yaml 草稿。请确认 run、sample_id、condition、replicate 和 control_group 后再写入正式 samples.yaml。'
+ : '已打开 samples.yaml 自动识别表。你可以直接修订样本、分组、重复和对照组。');
+ nlInput.value = '';
+ nlLoading.value = false;
+ return;
+ }
+ const isDataLocationStatement = pathMatch && !wantsExtensionStage && !isUtilitySequenceRequest(userInput) && (
+ lowerInput.includes('rnaseq') || lowerInput.includes('rna-seq') || lowerInput.includes('fastq') ||
+ lowerInput.includes('data') || lowerInput.includes('folder') || lowerInput.includes('directory') ||
+ lowerInput.includes('path') || lowerInput.includes('located') || lowerInput.includes('stored') ||
+ lowerInput.includes('reference') || lowerInput.includes('files are in') ||
+ userInput.includes('转录组') || userInput.includes('样本') || userInput.includes('数据目录') ||
+ userInput.includes('文件目录') || userInput.includes('文件在') || userInput.includes('数据在') ||
+ userInput.includes('分析数据') || userInput.includes('文件夹') || userInput.includes('目录') ||
+ userInput.includes('路径') || userInput.includes('放在') || userInput.includes('位于')
+ );
+ if (isDataLocationStatement) {
+ inputPath.value = pathMatch[1];
+ const beforeQueueLength = autofillAutoQueue.value.length;
+ const loadResult = await loadFromPath({
+ description: userInput,
+ speciesCode: inferredSpeciesCode,
+ analysisFamily: ''
+ });
+ const hasLoadDraftRows = (draft) => Array.isArray(draft?.rows) && draft.rows.length > 0;
+ const hasSamplesDraft = !!loadResult?.samples_draft_created && hasLoadDraftRows(loadResult?.samples_draft);
+ const hasConfigDraft = !!loadResult?.config_draft_created && hasLoadDraftRows(loadResult?.config_draft);
+ if (autofillAutoQueue.value.length > beforeQueueLength || autofillDialogVisible.value || hasSamplesDraft || hasConfigDraft) {
+ const msg = hasSamplesDraft
+ ? (hasConfigDraft
+ ? '已识别到 RNA-seq FASTQ 和参考资源,优先打开 samples.yaml 草稿供你核对样本、分组和对照;config.xlsx 草稿也已生成。'
+ : '已识别到 RNA-seq/FASTQ 数据并生成 samples.yaml 草稿。请在弹窗中核对 run、sample_id、condition、replicate、control_group 后再写入正式 samples.yaml。')
+ : '已把目录中的可识别参考资源导入当前 job,并生成 config.xlsx 草稿。请在弹窗中核对和修订,点击“确认并写入正式配置”后再执行正式分析。';
+ addAgentMessage('assistant', msg);
+ nlInput.value = '';
+ nlLoading.value = false;
+ return;
+ }
+ const loadedCount = loadResult?.count || 0;
+ addAgentMessage('assistant', loadedCount > 0
+ ? `已把路径中的 ${loadedCount} 个可识别文件软链接到当前 job,但没有发现需要自动填写 config.xlsx 或 samples.yaml 的文件类型;请检查目录或继续补充文件。`
+ : '我识别到你是在提供数据路径,但该路径下没有发现可用于 config.xlsx / samples.yaml 的文件。请确认目录是否包含 genome/pep/cds/gff/hmm 或 FASTQ 文件。');
+ nlInput.value = '';
+ nlLoading.value = false;
+ return;
+ }
+
nlStatusText.value = '正在识别意图和分析类型...';
const data = await requestAgentChatWithProgress({
message: userInput,
@@ -21363,6 +22441,23 @@ compare: [[flesh, peel]]`;
nlInput.value = '';
return;
}
+ if (data.intent.ui_action === 'open_config_dialog') {
+ await openAutofillDialog('config', {
+ description: userInput,
+ keepCurrentRows: false,
+ });
+ const opened = await openExistingAutofillDraft('config');
+ addAgentMessage('assistant', opened
+ ? (data.intent.message || '已打开当前 job 的 config.xlsx 核对表。')
+ : '当前 job 中没有找到可打开的 config.xlsx 草稿或正式配置。', {
+ stage: null,
+ taskType: 'ui_action',
+ params: { ui_action: data.intent.ui_action }
+ }, false);
+ nlLoading.value = false;
+ nlInput.value = '';
+ return;
+ }
if (data.action === 'reply' || data.action === 'clarify') {
addAgentMessage('assistant', data.intent.message || '请补充你的需求。', {
stage: null,
@@ -21601,6 +22696,7 @@ compare: [[flesh, peel]]`;
runtimeOverview.workers = Array.isArray(data.workers) ? data.workers : [];
runtimeOverview.queues = Array.isArray(data.queues) ? data.queues : [];
runtimeOverview.active_tasks = Array.isArray(data.active_tasks) ? data.active_tasks : [];
+ runtimeOverview.resource_tasks = Array.isArray(data.resource_tasks) ? data.resource_tasks : [];
runtimeOverview.summary = data.summary || {
worker_count: 0,
queue_count: 0,
@@ -21637,6 +22733,19 @@ compare: [[flesh, peel]]`;
tokenCostSummary.error = data.error || '';
};
+ const refreshResourceUsage = async () => {
+ try {
+ const response = await fetch('/api/resource/status', { cache: 'no-store' });
+ if (!response.ok) return;
+ const data = await response.json();
+ if (data && data.usage) {
+ runtimeOverview.resource_usage = data.usage;
+ }
+ } catch (_) {
+ // Celery overview continues to provide a slower fallback snapshot.
+ }
+ };
+
const refreshRuntimeOverview = async () => {
if (runtimeMonitorLoading.value) return;
runtimeMonitorLoading.value = true;
@@ -21785,8 +22894,11 @@ compare: [[flesh, peel]]`;
const startRuntimeMonitor = () => {
if (runtimeMonitorTimer) clearInterval(runtimeMonitorTimer);
+ if (resourceMonitorTimer) clearInterval(resourceMonitorTimer);
refreshRuntimeOverview();
+ refreshResourceUsage();
runtimeMonitorTimer = setInterval(refreshRuntimeOverview, 5000);
+ resourceMonitorTimer = setInterval(refreshResourceUsage, 1000);
};
const stopRuntimeMonitor = () => {
@@ -21794,6 +22906,10 @@ compare: [[flesh, peel]]`;
clearInterval(runtimeMonitorTimer);
runtimeMonitorTimer = null;
}
+ if (resourceMonitorTimer) {
+ clearInterval(resourceMonitorTimer);
+ resourceMonitorTimer = null;
+ }
};
const activeGeneFamilyScopeId = () => {
@@ -21969,14 +23085,18 @@ compare: [[flesh, peel]]`;
stopRuntimeMonitor();
};
- onMounted(() => {
- refreshInfo();
- refreshResults();
+ onMounted(async () => {
+ await refreshInfo();
+ await loadConversations();
+ if (!currentConversation.value?.job_id && jobInfo.job_id) {
+ await refreshResults(jobInfo.job_id);
+ }
fetchAgentTasks();
startAgentTaskPolling();
startRuntimeMonitor();
- loadConversations(); // 加载会话列表
loadExtensionStages();
+ loadLightweightExtensionStages();
+ resumeLightweightOperation();
loadExtensionRuns();
loadMethodologyJobs();
@@ -22177,11 +23297,16 @@ compare: [[flesh, peel]]`;
executeStagesWithDependencies,
executeStageWithConfirmation,
stages,
- visibleStages,
+ resultGroups,
+ activeResultGroups,
resultStageFilter,
setResultStageFilter,
// 异步任务轮询
currentTask,
+ showExecutionIndicator,
+ executionIndicatorStage,
+ executionIndicatorElapsed,
+ executionIndicatorStatusText,
// Stop Execution相关
isExecuting,
isStopping,
@@ -22303,6 +23428,8 @@ compare: [[flesh, peel]]`;
extensionBuild,
extensionStages,
extensionCompletedStages,
+ lightweightExtensionConversation,
+ isLightweightExtensionSelected,
extensionStageOptionLabel,
selectExistingExtensionStage,
extensionRuns,
@@ -22319,6 +23446,16 @@ compare: [[flesh, peel]]`;
onExtensionDataFilesChange,
removeExtensionFile,
buildExtensionStage,
+ confirmLightweightCandidate,
+ cancelLightweightCard,
+ beginLightweightRevision,
+ reviseLightweightCandidate,
+ retryLightweightSemantic,
+ validateLightweightExtension,
+ reviewLightweightResult,
+ publishLightweightExtension,
+ openLightweightRuntimeCard,
+ runLightweightExtension,
loadExtensionPreview,
openExtensionConfirm,
trialRunExtensionStage,
diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py
index 1df1027c2..11a08cfba 100644
--- a/apps/web/vue3/main.py
+++ b/apps/web/vue3/main.py
@@ -665,19 +665,10 @@ def _apply_scope_selection_to_intent(intent: Dict[str, Any], message: str) -> No
params["geneFamily"] = family
-def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
- """Read species roles only when the job has authoritative config confirmation."""
+def _configured_species_scope(job_id: str) -> Dict[str, list[str]]:
+ """Read the species roles declared by the current job config."""
if not job_id:
return {}
- try:
- from gfa_core.mas.confirmation_snapshot import build_confirmation_snapshot
-
- confirmation = build_confirmation_snapshot(PROJECT_ROOT, job_id)
- except (OSError, ValueError):
- return {}
- if not confirmation.get("config_confirmed"):
- return {}
-
config_path = INPUT_ROOT / job_id / "config.xlsx"
try:
import pandas as pd
@@ -705,6 +696,21 @@ def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
return scope if any(scope.values()) else {}
+def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]:
+ """Read species roles only when the job has authoritative config confirmation."""
+ if not job_id:
+ return {}
+ try:
+ from gfa_core.mas.confirmation_snapshot import build_confirmation_snapshot
+
+ confirmation = build_confirmation_snapshot(PROJECT_ROOT, job_id)
+ except (OSError, ValueError):
+ return {}
+ if not confirmation.get("config_confirmed"):
+ return {}
+ return _configured_species_scope(job_id)
+
+
def _normalize_main_agent_analysis_intent(
intent: Dict[str, Any],
*,
@@ -1137,7 +1143,7 @@ def _load_identify_species_selection(job_id: str) -> Dict[str, Any]:
current_role = entry.get("role", "unspecified")
if role == "target":
entry["role"] = "target"
- elif role in {"ref", "seed_reference", "known_family_reference"} and current_role != "target":
+ elif role in {"ref", "reference", "seed_reference", "known_family_reference"} and current_role != "target":
entry["role"] = role
elif role in {"comparative", "compactive", "outgroup"} and current_role not in {"target", "ref", "seed_reference", "known_family_reference"}:
entry["role"] = role
@@ -1185,7 +1191,7 @@ def _load_identify_species_selection(job_id: str) -> Dict[str, Any]:
groups["target"].append(item)
elif role in {"comparative", "compactive", "outgroup"}:
groups["comparative"].append(item)
- elif role in {"ref", "seed_reference", "known_family_reference"}:
+ elif role in {"ref", "reference", "seed_reference", "known_family_reference"}:
groups["reference"].append(item)
else:
groups["other"].append(item)
@@ -1344,6 +1350,53 @@ def _extract_species_codes_from_text_for_job(job_id: str, text: str) -> list[str
return ordered
+def _resolve_subfamily_request_species_roles(
+ job_id: str,
+ message: str,
+ intent: Optional[Dict[str, Any]] = None,
+) -> Dict[str, list[str]]:
+ """Preserve config roles while resolving species mentioned in a subfamily request."""
+ routed_intent = intent or {}
+ params = routed_intent.get("params") or {}
+ structured_targets = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(params.get("targetSpecies") or routed_intent.get("target_species")),
+ )
+ structured_refs = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(params.get("referenceSpecies") or routed_intent.get("reference_species")),
+ )
+ structured_comps = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(params.get("comparativeSpecies") or routed_intent.get("comparative_species")),
+ )
+ mentioned = _extract_species_codes_from_text_for_job(job_id, message or "")
+ configured = _configured_species_scope(job_id)
+ configured_targets = _canonicalize_species_codes_for_job(job_id, configured.get("targetSpecies") or [])
+ configured_refs = _canonicalize_species_codes_for_job(job_id, configured.get("referenceSpecies") or [])
+ configured_comps = _canonicalize_species_codes_for_job(job_id, configured.get("comparativeSpecies") or [])
+ if configured:
+ target_candidates = [*structured_targets, *mentioned]
+ comp_candidates = [*structured_comps, *mentioned]
+ ref_candidates = [*structured_refs, *mentioned]
+ targets = [species for species in configured_targets if species in target_candidates]
+ comps = [species for species in configured_comps if species in comp_candidates]
+ refs = [species for species in configured_refs if species in ref_candidates]
+ if not refs:
+ refs = list(configured_refs)
+ else:
+ targets = structured_targets or mentioned
+ refs = structured_refs
+ comps = structured_comps
+ classification_species = list(dict.fromkeys([*targets, *comps]))
+ return {
+ "target_species": targets,
+ "reference_species": refs,
+ "comparative_species": comps,
+ "classification_species": classification_species,
+ }
+
+
def _species_groups_from_selection(selection: Dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
groups = selection.get("groups", {}) if isinstance(selection, dict) else {}
@@ -1584,6 +1637,10 @@ def _split_subfamily_selected_species_by_config_roles(
selected_set = set(selected)
target_role = _subfamily_group_species(identify_selection or {}, ["target"])
comparative_role = _subfamily_group_species(identify_selection or {}, ["comparative", "other"])
+ if not target_role and not comparative_role:
+ configured = _configured_species_scope(job_id)
+ target_role = _canonicalize_species_codes_for_job(job_id, configured.get("targetSpecies") or [])
+ comparative_role = _canonicalize_species_codes_for_job(job_id, configured.get("comparativeSpecies") or [])
targets = [sp for sp in target_role if sp in selected_set]
comps = [sp for sp in comparative_role if sp in selected_set and sp not in targets]
if not targets:
@@ -1726,18 +1783,47 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
return list(dict.fromkeys(stages))
-def _resolve_contextual_gene_family_request(job_id: str, message: str, request_prefix: str = "") -> Dict[str, Any]:
+def _resolve_contextual_gene_family_request(
+ job_id: str,
+ message: str,
+ request_prefix: str = "",
+ requested_stages: list[str] | None = None,
+ request_intent: Dict[str, Any] | None = None,
+) -> Dict[str, Any]:
"""Resolve post-subfamily/default phylogeny context from real project state.
This is deliberately rule-based: it decides scope/route boundaries from
files and config, while LLM/intent parsing may still supply user wording.
"""
text = str(message or "")
+ structured_stages = list(dict.fromkeys(
+ str(stage).strip()
+ for stage in (requested_stages or [])
+ if str(stage).strip()
+ ))
+ contextual_stage_request = bool(
+ set(structured_stages) & {"phylogeny", "motif", "domain", "structure", "combo"}
+ )
+ routed_intent = request_intent or {}
+ routed_params = routed_intent.get("params") or {}
+ structured_targets = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(routed_params.get("targetSpecies") or routed_intent.get("target_species")),
+ )
+ structured_refs = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(routed_params.get("referenceSpecies") or routed_intent.get("reference_species")),
+ )
+ structured_comps = _canonicalize_species_codes_for_job(
+ job_id,
+ _param_list(routed_params.get("comparativeSpecies") or routed_intent.get("comparative_species")),
+ )
if not (
_looks_like_phylogeny_query(text)
or _looks_like_gene_family_workflow_query(text)
or _looks_like_intraspecies_synteny_query(text)
or _looks_like_tfbs_query(text)
+ or contextual_stage_request
):
return {}
selection = _load_identify_species_selection(job_id)
@@ -1762,11 +1848,13 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
if sp not in mentioned_targets:
mentioned_targets.append(sp)
- has_explicit_scope = bool(mentioned)
+ has_explicit_scope = bool(mentioned or structured_targets or structured_refs or structured_comps)
is_all_species_request = _looks_like_all_species_query(text) or _looks_like_all_subfamily_species_query(text)
is_intraspecies_synteny_request = _looks_like_intraspecies_synteny_query(text)
- if has_explicit_scope:
+ if mentioned:
targets = mentioned_targets or [sp for sp in mentioned if sp not in mentioned_refs and sp not in mentioned_comps]
+ elif structured_targets:
+ targets = list(structured_targets)
elif is_all_species_request and config_targets:
targets = list(config_targets)
elif is_intraspecies_synteny_request and config_targets:
@@ -1780,8 +1868,8 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
else:
targets = []
- refs = mentioned_refs or (list(config_refs) if is_all_species_request else [])
- comps = mentioned_comps or (list(config_comps) if is_all_species_request else [])
+ refs = mentioned_refs or list(structured_refs) or (list(config_refs) if is_all_species_request else [])
+ comps = mentioned_comps or list(structured_comps) or (list(config_comps) if is_all_species_request else [])
if refs and comps:
scope_mode = "target_plus_reference_plus_comparative"
elif refs:
@@ -1793,7 +1881,7 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
if not family or not targets:
return {}
- stages = _downstream_gene_family_stages_for_context(text)
+ stages = structured_stages or _downstream_gene_family_stages_for_context(text)
subfamily_label_stages = {"phylogeny", "motif", "domain", "structure", "combo"}
needs_subfamily_labels_for_stage = any(stage in subfamily_label_stages for stage in stages)
prefix = f"{targets[0]}.{family}" if len(targets) == 1 else f"Target.{family}"
@@ -1826,6 +1914,7 @@ def _resolve_contextual_gene_family_request(job_id: str, message: str, request_p
or _requires_confirmed_subfamily_query(text)
or _looks_like_gene_family_workflow_query(text)
or _looks_like_phylogeny_query(text)
+ or contextual_stage_request
)
)
requires_confirmed = wants_subfamily_context
@@ -3870,6 +3959,9 @@ def _attach_agent_manifest_status(task: Dict) -> None:
print(f"[API DEBUG] Failed to attach agent manifest status: {e}")
app = FastAPI(title="BotanicalBaseAgent API")
+from gfa_core.extension_stage_lightweight.api import create_lightweight_extension_router
+
+app.include_router(create_lightweight_extension_router())
# MainAgent runtime state. Analysis planning and execution remain owned by
# the original IntentAgent/PlanningAgent/frontend flow below.
llm_manager = None
@@ -4115,11 +4207,15 @@ async def link_agent_conversation_to_job(
store = globals().get("agent_conversation_store")
if store is None:
raise HTTPException(status_code=503, detail="会话存储尚未初始化")
- store.rebind_job(
+ conversation_id = store.rebind_job(
session_id=request.conversation_id,
job_id=request.job_id,
)
- return {"success": True, "message": "会话已关联 Working Directory"}
+ return {
+ "success": True,
+ "conversation_id": conversation_id,
+ "message": "会话已关联 Working Directory",
+ }
@@ -6014,6 +6110,13 @@ def _api_semantic_preflight(job_id: str, stage: str, prefix: str, params: Dict[s
decisions[decision_key] = decision.to_dict() if decision else None
if decision and decision.resolved:
+ if (
+ need.role == "tree"
+ and decision.selected
+ and decision.selected.path
+ and not str(params.get("treeFile") or "").strip()
+ ):
+ materialized_params["treeFile"] = decision.selected.path
if (
stage == "wgs_index"
and need.role == "reference_fasta"
@@ -12598,7 +12701,7 @@ def _get_celery_overview() -> Dict[str, Any]:
resource = snapshot(include_terminal=False)
overview["resource_usage"] = resource.get("usage")
- overview["resource_tasks"] = resource.get("tasks", [])[:20]
+ overview["resource_tasks"] = resource.get("tasks", [])
except Exception as exc:
overview["errors"].append(f"Resource governor snapshot failed: {exc}")
overview["thresholds"] = {
@@ -13925,13 +14028,6 @@ async def get_results(
def _add_manifest_result_group(manifest_file: Path, manifest: dict):
if "combo_panel_test" in manifest_file.name.lower():
return
- if _is_subfamily_intermediate_manifest(manifest_file, manifest):
- # These manifests are intermediate evidence generated inside the
- # reference_classification branch. Final combo/circular tree/MSA
- # artifacts are exposed under 2.5 亚家族确认 instead.
- _remember_hidden_manifest_artifacts(manifest_file, manifest)
- hidden_intermediate_artifact_names.add(manifest_file.name)
- return
group_name = (
manifest.get("result_group")
or (manifest.get("parameters") or {}).get("result_group")
@@ -14206,6 +14302,12 @@ async def get_results(
except Exception:
pass
+ # Results are a persisted job view. Apply manifests by execution time so
+ # the latest attempt determines the visible stage status.
+ manifest_payloads.sort(
+ key=lambda item: (item[0].stat().st_mtime, item[0].name)
+ )
+
# A refreshed GWAS format result request may not carry the genotype scope.
# Rehydrate it only when the requested prefix (if any) identifies exactly
# one successful scientific scope; otherwise remain fail-closed.
@@ -16729,6 +16831,7 @@ async def _run_agent_chat(
{
"taskType": task_type,
"message": outcome_message,
+ "ui_action": outcome.get("ui_action"),
"job_id": request.job_id,
"workspace": (
str(PROJECT_ROOT / "workspace" / request.job_id)
@@ -17904,7 +18007,7 @@ def _build_contextual_gene_family_plan_response(request: Any, session_id: str, c
# Subfamily review happens after reference_classification.
if "identify" in stage_ids:
detail["dependencies"] = ["identify"]
- if contextual_request.get("scopeMode") != "target_only" and contextual_request.get("context_route") in {"custom_phylogeny_scope", "plain_phylogeny"}:
+ if not needs_subfamily_evidence_chain and contextual_request.get("scopeMode") != "target_only" and contextual_request.get("context_route") in {"custom_phylogeny_scope", "plain_phylogeny"}:
detail_params["analysisRoute"] = "custom_phylogeny"
detail["params"] = enrich_stage_params_with_context(stage_id, plan_prefix, detail_params)
stages_detail.append(detail)
@@ -19652,8 +19755,12 @@ async def agent_plan(request: AgentPlanRequest):
request.message or "",
subfamily=True,
)
- explicit_species_for_subfamily = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
- targets_for_subfamily = explicit_species_for_subfamily or _target_species_fallback_from_prefix(requested_prefix_for_subfamily)
+ request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
+ request.job_id,
+ request.message or "",
+ request.intent,
+ )
+ targets_for_subfamily = request_roles_for_subfamily["classification_species"] or _target_species_fallback_from_prefix(requested_prefix_for_subfamily)
if not targets_for_subfamily and prefix_species and prefix_species not in {"Target", "Multi"}:
targets_for_subfamily = [prefix_species]
scope_defaults_for_subfamily = _latest_gene_family_scope_defaults(request.job_id, family_for_subfamily, requested_prefix_for_subfamily)
@@ -19675,7 +19782,7 @@ async def agent_plan(request: AgentPlanRequest):
*(scope_defaults_for_subfamily.get("target_species") or []),
*(scope_defaults_for_subfamily.get("comparative_species") or []),
])) or targets_for_subfamily
- refs_for_subfamily = scope_defaults_for_subfamily.get("reference_species") or [
+ refs_for_subfamily = request_roles_for_subfamily["reference_species"] or scope_defaults_for_subfamily.get("reference_species") or [
item.get("species")
for item in ((identify_selection_for_subfamily.get("groups") or {}).get("reference") or [])
if item.get("species")
@@ -19828,6 +19935,8 @@ async def agent_plan(request: AgentPlanRequest):
request.job_id,
request.message,
request.prefix,
+ requested_stages=list((request.intent or {}).get("stages") or []),
+ request_intent=request.intent,
)
if contextual_request_pre_mas and not subfamily_prediction_query_for_context and not subfamily_confirm_query_for_context:
response = _build_contextual_gene_family_plan_response(request, session_id, contextual_request_pre_mas)
@@ -19978,12 +20087,16 @@ async def agent_plan(request: AgentPlanRequest):
and not _requires_confirmed_subfamily_query(query_lower_early)
)
if subfamily_prediction_query_early:
- explicit_species = _extract_species_codes_from_text_for_job(request.job_id, request.message or "")
+ request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
+ request.job_id,
+ request.message or "",
+ incoming_intent,
+ )
current_targets = _canonicalize_species_codes_for_job(
request.job_id,
_param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")),
)
- target_species_for_subfamily = explicit_species or current_targets
+ target_species_for_subfamily = request_roles_for_subfamily["classification_species"] or current_targets
family_for_subfamily = _resolve_authoritative_gene_family(
request.job_id,
incoming_params.get("geneFamily") or incoming_intent.get("gene_family"),
@@ -19996,7 +20109,7 @@ async def agent_plan(request: AgentPlanRequest):
if not target_species_for_subfamily:
target_species_for_subfamily = scope_defaults_for_subfamily.get("target_species") or _target_species_fallback_from_prefix(effective_plan_prefix or requested_prefix)
identify_selection_for_subfamily = _load_identify_species_selection(request.job_id) if request.job_id else {}
- reference_defaults_for_subfamily = _primary_reference_species_from_scope(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
+ reference_defaults_for_subfamily = request_roles_for_subfamily["reference_species"] or _primary_reference_species_from_scope(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
if not reference_defaults_for_subfamily:
reference_defaults_for_subfamily = scope_defaults_for_subfamily.get("reference_species") or []
if not reference_defaults_for_subfamily:
@@ -20014,8 +20127,8 @@ async def agent_plan(request: AgentPlanRequest):
incoming_intent["reference_species"] = reference_defaults_for_subfamily
incoming_params["scopeMode"] = "target_plus_reference"
incoming_intent["scopeMode"] = "target_plus_reference"
- incoming_params["comparativeSpecies"] = []
- incoming_intent["comparative_species"] = []
+ incoming_params["comparativeSpecies"] = request_roles_for_subfamily["comparative_species"]
+ incoming_intent["comparative_species"] = request_roles_for_subfamily["comparative_species"]
incoming_params["geneFamily"] = family_for_subfamily
incoming_intent["gene_family"] = family_for_subfamily
if target_species_for_subfamily and family_for_subfamily:
@@ -20036,14 +20149,19 @@ async def agent_plan(request: AgentPlanRequest):
request.message or "",
subfamily=True,
)
- targets_for_subfamily = _canonicalize_species_codes_for_job(
+ request_roles_for_subfamily = _resolve_subfamily_request_species_roles(
request.job_id,
- _param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")) or _extract_species_codes_from_text_for_job(request.job_id, request.message or ""),
+ request.message or "",
+ incoming_intent,
+ )
+ targets_for_subfamily = request_roles_for_subfamily["classification_species"] or _canonicalize_species_codes_for_job(
+ request.job_id,
+ _param_list(incoming_params.get("targetSpecies")) or _param_list(incoming_intent.get("target_species")),
)
scope_defaults_for_subfamily = _latest_gene_family_scope_defaults(request.job_id, family_for_subfamily, effective_plan_prefix or requested_prefix)
if not targets_for_subfamily:
targets_for_subfamily = scope_defaults_for_subfamily.get("target_species") or _target_species_fallback_from_prefix(effective_plan_prefix or requested_prefix)
- refs_for_subfamily = _canonicalize_species_codes_for_job(
+ refs_for_subfamily = request_roles_for_subfamily["reference_species"] or _canonicalize_species_codes_for_job(
request.job_id,
_param_list(incoming_params.get("referenceSpecies")) or _param_list(incoming_intent.get("reference_species")) or scope_defaults_for_subfamily.get("reference_species"),
)
@@ -20246,7 +20364,13 @@ async def agent_plan(request: AgentPlanRequest):
print(f"[Agent Plan] Deterministic subfamily intent plan: targets={targets_for_subfamily}, refs={refs_for_subfamily}, stages={deterministic_stages}")
return response
- contextual_request_early = _resolve_contextual_gene_family_request(request.job_id, request.message, request.prefix)
+ contextual_request_early = _resolve_contextual_gene_family_request(
+ request.job_id,
+ request.message,
+ request.prefix,
+ requested_stages=list(incoming_intent.get("stages") or []),
+ request_intent=incoming_intent,
+ )
if contextual_request_early and not subfamily_prediction_query_early and not subfamily_confirm_query_early:
response = _build_contextual_gene_family_plan_response(request, session_id, contextual_request_early)
plan_for_log = response.get('plan') or {}
@@ -20939,7 +21063,13 @@ async def agent_plan(request: AgentPlanRequest):
for key in ("targetSpecies", "referenceSpecies", "comparativeSpecies", "scopeMode", "geneFamily"):
if scope_params.get(key):
plan_params[key] = scope_params[key]
- contextual_request = _resolve_contextual_gene_family_request(request.job_id, request.message, request.prefix)
+ contextual_request = _resolve_contextual_gene_family_request(
+ request.job_id,
+ request.message,
+ request.prefix,
+ requested_stages=list(intent.get("stages") or []),
+ request_intent=intent,
+ )
if contextual_request and not subfamily_prediction_query and not subfamily_confirm_query:
stage_ids = list(contextual_request.get("stages") or [])
raw_stages = stage_ids
diff --git a/celery_app.py b/celery_app.py
index cbd6fc412..3f05d5d24 100755
--- a/celery_app.py
+++ b/celery_app.py
@@ -61,6 +61,10 @@ app.conf.update(
task_annotations={
"tasks.execute_agent_task": {"time_limit": 3600, "soft_time_limit": 3300},
"tasks.execute_single_stage_task": {"time_limit": 172800, "soft_time_limit": 169200},
+ "tasks.execute_lightweight_extension_operation": {
+ "time_limit": None,
+ "soft_time_limit": None,
+ },
},
worker_prefetch_multiplier=1,
)
diff --git a/gfa_core/extension_stage/raw_package_normalizer.py b/gfa_core/extension_stage/raw_package_normalizer.py
new file mode 100644
index 000000000..b06dd8bed
--- /dev/null
+++ b/gfa_core/extension_stage/raw_package_normalizer.py
@@ -0,0 +1,182 @@
+"""Normalize uploaded runnable code into one concrete extension entry command."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any
+
+
+SHELL_SUFFIXES = {".sh", ".bash", ".command"}
+PYTHON_SUFFIXES = {".py"}
+R_SUFFIXES = {".r"}
+
+
+def normalize_raw_package(
+ *,
+ explicit_command: str,
+ raw_code: Path,
+ raw_data: Path,
+ saved_code: list[Path],
+) -> dict[str, Any]:
+ """Return a deterministic entry command record for an uploaded raw package."""
+ raw_code = Path(raw_code)
+ raw_data = Path(raw_data)
+ explicit_command = str(explicit_command or "").strip()
+ scripts = _script_inventory(saved_code, raw_code)
+
+ if explicit_command and not saved_code:
+ run_sh = raw_code / "run.sh"
+ run_sh.parent.mkdir(parents=True, exist_ok=True)
+ run_sh.write_text("#!/usr/bin/env bash\nset -euo pipefail\n\n" + explicit_command + "\n", encoding="utf-8")
+ run_sh.chmod(0o755)
+ return _record(
+ entry_command="bash run.sh",
+ entry_source="inline_command_wrapped",
+ entry_confidence="high",
+ requires_user_confirmation=False,
+ scripts=["run.sh"],
+ warnings=[],
+ raw_data=raw_data,
+ )
+
+ if explicit_command:
+ return _record(
+ entry_command=explicit_command,
+ entry_source="user_command",
+ entry_confidence="high",
+ requires_user_confirmation=False,
+ scripts=scripts,
+ warnings=[],
+ raw_data=raw_data,
+ )
+
+ shell_scripts = _scripts_by_suffix(saved_code, raw_code, SHELL_SUFFIXES)
+ if len(shell_scripts) == 1:
+ return _record(
+ entry_command=f"bash {shell_scripts[0]}",
+ entry_source="single_shell_script",
+ entry_confidence="high",
+ requires_user_confirmation=False,
+ scripts=scripts,
+ warnings=[],
+ raw_data=raw_data,
+ )
+ if len(shell_scripts) > 1:
+ ranked = _rank_shell_scripts(shell_scripts)
+ ambiguous = len(ranked) > 1 and _shell_rank_key(ranked[0]) == _shell_rank_key(ranked[1])
+ return _record(
+ entry_command=f"bash {ranked[0]}",
+ entry_source="ambiguous_shell_script" if ambiguous else "ranked_shell_script",
+ entry_confidence="low" if ambiguous else "medium",
+ requires_user_confirmation=ambiguous,
+ scripts=scripts,
+ warnings=["multiple shell entry candidates detected"] if ambiguous else [],
+ raw_data=raw_data,
+ )
+
+ python_scripts = _scripts_by_suffix(saved_code, raw_code, PYTHON_SUFFIXES)
+ if len(python_scripts) == 1:
+ return _record(
+ entry_command=f"python {python_scripts[0]}",
+ entry_source="single_python_script",
+ entry_confidence="medium",
+ requires_user_confirmation=False,
+ scripts=scripts,
+ warnings=["python script parameters may require confirmation"],
+ raw_data=raw_data,
+ )
+
+ r_scripts = _scripts_by_suffix(saved_code, raw_code, R_SUFFIXES)
+ if len(r_scripts) == 1:
+ return _record(
+ entry_command=f"Rscript {r_scripts[0]}",
+ entry_source="single_r_script",
+ entry_confidence="medium",
+ requires_user_confirmation=False,
+ scripts=scripts,
+ warnings=["R script parameters may require confirmation"],
+ raw_data=raw_data,
+ )
+
+ if saved_code:
+ first = sorted(saved_code, key=lambda p: p.relative_to(raw_code).as_posix().lower())[0]
+ rel = first.relative_to(raw_code).as_posix()
+ return _record(
+ entry_command=rel,
+ entry_source="fallback_code_file",
+ entry_confidence="low",
+ requires_user_confirmation=True,
+ scripts=scripts,
+ warnings=["could not identify a standard runnable entry script"],
+ raw_data=raw_data,
+ )
+
+ return _record(
+ entry_command="",
+ entry_source="missing_entry",
+ entry_confidence="none",
+ requires_user_confirmation=True,
+ scripts=[],
+ warnings=["no command or code file was provided"],
+ raw_data=raw_data,
+ )
+
+
+def _record(
+ *,
+ entry_command: str,
+ entry_source: str,
+ entry_confidence: str,
+ requires_user_confirmation: bool,
+ scripts: list[str],
+ warnings: list[str],
+ raw_data: Path,
+) -> dict[str, Any]:
+ return {
+ "schema_version": "raw_package_normalized_entry_v1",
+ "entry_command": entry_command,
+ "entry_source": entry_source,
+ "entry_confidence": entry_confidence,
+ "requires_user_confirmation": bool(requires_user_confirmation),
+ "detected_scripts": sorted(scripts),
+ "raw_test_data_files": sorted(p.relative_to(raw_data).as_posix() for p in raw_data.rglob("*") if p.is_file()),
+ "normalization_warnings": warnings,
+ }
+
+
+def _script_inventory(paths: list[Path], raw_code: Path) -> list[str]:
+ return [
+ path.relative_to(raw_code).as_posix()
+ for path in paths
+ if path.is_file() and path.suffix.lower() in SHELL_SUFFIXES | PYTHON_SUFFIXES | R_SUFFIXES
+ ]
+
+
+def _scripts_by_suffix(paths: list[Path], raw_code: Path, suffixes: set[str]) -> list[str]:
+ return sorted(
+ path.relative_to(raw_code).as_posix()
+ for path in paths
+ if path.is_file() and path.suffix.lower() in suffixes
+ )
+
+
+def _rank_shell_scripts(scripts: list[str]) -> list[str]:
+ return sorted(scripts, key=lambda name: (_shell_rank_key(name), name.lower()))
+
+
+def _shell_rank_key(name: str) -> int:
+ base = Path(name).name.lower()
+ stem = re.sub(r"\.(sh|bash|command)$", "", base)
+ if stem in {"run", "main"}:
+ return 0
+ if stem.startswith("run_") or stem.startswith("run-") or stem.startswith("run"):
+ return 1
+ if stem.endswith("_run") or stem.endswith("-run"):
+ return 2
+ if stem.startswith("main_") or stem.startswith("main-") or stem.startswith("main"):
+ return 3
+ if "install" in stem or "clean" in stem:
+ return 9
+ return 5
+
diff --git a/gfa_core/extension_stage_lightweight/__init__.py b/gfa_core/extension_stage_lightweight/__init__.py
new file mode 100644
index 000000000..b41293154
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/__init__.py
@@ -0,0 +1,20 @@
+"""Deterministic lightweight converter for already-runnable analysis packages."""
+
+from .evidence import run_raw_baseline, stage_package
+from .runtime_binding import (
+ RuntimeBindingError,
+ build_runtime_binding_contract,
+ render_runtime_package,
+ replay_runtime_binding,
+ resolve_runtime_bindings,
+)
+
+__all__ = [
+ "RuntimeBindingError",
+ "build_runtime_binding_contract",
+ "render_runtime_package",
+ "replay_runtime_binding",
+ "resolve_runtime_bindings",
+ "run_raw_baseline",
+ "stage_package",
+]
diff --git a/gfa_core/extension_stage_lightweight/api.py b/gfa_core/extension_stage_lightweight/api.py
new file mode 100644
index 000000000..09e5927c4
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/api.py
@@ -0,0 +1,739 @@
+"""Thin asynchronous HTTP boundary for the lightweight extension service."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from dataclasses import dataclass
+import fcntl
+import json
+import os
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Any, Callable
+from uuid import uuid4
+
+from fastapi import APIRouter, File, Form, HTTPException, UploadFile
+from fastapi.responses import FileResponse
+from pydantic import BaseModel, Field
+
+from gfa_core.extension_stage.raw_package_normalizer import normalize_raw_package
+
+from .service import LightweightExtensionService
+
+
+UPLOAD_CHUNK_BYTES = 1024 * 1024
+
+
+def _safe_component(value: str, *, name: str) -> str:
+ value = str(value or "").strip()
+ if not value or value in {".", ".."} or "/" in value or "\\" in value:
+ raise ValueError(f"{name} must be one safe path component")
+ return value
+
+
+def _relative_path(value: str) -> Path:
+ path = Path(value or "")
+ if (
+ not value
+ or "\\" in value
+ or path.is_absolute()
+ or any(part in {"", ".", ".."} for part in path.parts)
+ ):
+ raise ValueError("uploaded filename must be a safe relative path")
+ return path
+
+
+async def _copy_upload_file(upload: UploadFile, destination: Path) -> None:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with destination.open("wb") as handle:
+ while chunk := await upload.read(UPLOAD_CHUNK_BYTES):
+ handle.write(chunk)
+
+
+def _server_data_files(value: str | None) -> list[tuple[Path, Path]]:
+ files: list[tuple[Path, Path]] = []
+ for raw in (value or "").splitlines():
+ text = raw.strip()
+ if not text:
+ continue
+ source = Path(text).expanduser()
+ if not source.is_absolute():
+ raise ValueError(f"测试数据路径必须是绝对路径:{text}")
+ if not source.exists():
+ raise ValueError(f"测试数据路径不存在:{text}")
+ if source.is_file():
+ files.append((Path(source.name), source.resolve()))
+ continue
+ if not source.is_dir():
+ raise ValueError(f"测试数据路径不是普通文件或目录:{text}")
+ files.extend(
+ (item.relative_to(source), item.resolve())
+ for item in sorted(source.rglob("*"))
+ if item.is_file()
+ )
+ return files
+
+
+def _path_from_env(value: str, *, project_root: Path) -> Path:
+ path = Path(value).expanduser()
+ return path if path.is_absolute() else project_root / path
+
+
+@dataclass(frozen=True)
+class LightweightApiSettings:
+ project_root: Path
+ store_root: Path
+ input_root: Path
+ workspace_root: Path
+
+ @classmethod
+ def from_environment(
+ cls, *, environ: dict[str, str] | None = None, project_root: Path | None = None
+ ) -> "LightweightApiSettings":
+ env = os.environ if environ is None else environ
+ default_root = Path(__file__).resolve().parents[2]
+ root_value = str(
+ env.get("BBAV5_PROJECT_ROOT") or env.get("PROJECT_ROOT") or project_root or default_root
+ )
+ root = _path_from_env(root_value, project_root=default_root).resolve()
+ return cls(
+ project_root=root,
+ store_root=_path_from_env(
+ str(env.get("BBAV5_LIGHTWEIGHT_EXTENSION_STORE") or "extension_stages_lightweight"),
+ project_root=root,
+ ),
+ input_root=_path_from_env(
+ str(env.get("BBAV5_LIGHTWEIGHT_INPUT") or "input"), project_root=root
+ ),
+ workspace_root=_path_from_env(
+ str(env.get("BBAV5_LIGHTWEIGHT_WORKSPACE") or "workspace"), project_root=root
+ ),
+ )
+
+
+def _runtime_input_path(
+ settings: LightweightApiSettings, run_job_id: str, value: str
+) -> Path:
+ path = Path(value).expanduser()
+ if path.is_absolute():
+ return path
+ parts = path.parts
+ if len(parts) >= 3 and parts[1] == run_job_id:
+ if parts[0] == "input":
+ return settings.input_root / run_job_id / Path(*parts[2:])
+ if parts[0] == "workspace":
+ return settings.workspace_root / run_job_id / Path(*parts[2:])
+ return path
+
+
+class _TokenRequest(BaseModel):
+ operation_token: str
+
+
+class _ReviseRequest(_TokenRequest):
+ feedback: str
+ revision_target: str
+
+
+class _RunRequest(_TokenRequest):
+ run_job_id: str
+ input_overrides: dict[str, str] = Field(default_factory=dict)
+ parameter_overrides: dict[str, Any] = Field(default_factory=dict)
+ explicit_prefix: str | None = None
+
+
+class _IntentRequest(BaseModel):
+ extension_id: str | None = None
+ message: str = ""
+
+
+def _receipt_path(settings: LightweightApiSettings, token: str) -> Path:
+ return settings.store_root / "operations" / f"{_safe_component(token, name='operation_token')}.json"
+
+
+def _read_json(path: Path) -> dict[str, Any] | None:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return None
+
+
+@contextmanager
+def _token_lock(settings: LightweightApiSettings, token: str):
+ token = _safe_component(token, name="operation_token")
+ lock_path = settings.store_root / "operations" / ".locks" / f"{token}.lock"
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ with lock_path.open("a+") as handle:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ try:
+ yield
+ finally:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+
+
+def _write_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ mode="w", encoding="utf-8", dir=path.parent, prefix=".operation-", delete=False
+ ) as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
+ handle.write("\n")
+ temporary = Path(handle.name)
+ temporary.replace(path)
+
+
+def _reserve_operation_locked(
+ settings: LightweightApiSettings, *, extension_id: str, action: str, token: str
+) -> tuple[dict[str, Any], bool]:
+ path = _receipt_path(settings, token)
+ existing = _read_json(path)
+ if existing is not None:
+ if existing.get("extension_id") != extension_id or existing.get("action") != action:
+ raise ValueError("operation_token is already bound to another action")
+ return existing, False
+ receipt = _queued_receipt(extension_id=extension_id, action=action, token=token)
+ _write_json(path, receipt)
+ return receipt, True
+
+
+def _queued_receipt(*, extension_id: str, action: str, token: str) -> dict[str, Any]:
+ return {
+ "operation_id": token,
+ "operation_token": token,
+ "extension_id": extension_id,
+ "action": action,
+ "state": "queued",
+ "status": "queued",
+ }
+
+
+def _reserve_operation(
+ settings: LightweightApiSettings, *, extension_id: str, action: str, token: str
+) -> tuple[dict[str, Any], bool]:
+ with _token_lock(settings, token):
+ return _reserve_operation_locked(
+ settings, extension_id=extension_id, action=action, token=token
+ )
+
+
+def _set_receipt_locked(
+ settings: LightweightApiSettings, token: str, **changes: Any
+) -> dict[str, Any]:
+ path = _receipt_path(settings, token)
+ receipt = _read_json(path)
+ if receipt is None:
+ raise ValueError("operation does not exist")
+ receipt.update(changes)
+ _write_json(path, receipt)
+ return receipt
+
+
+def _set_receipt(settings: LightweightApiSettings, token: str, **changes: Any) -> dict[str, Any]:
+ with _token_lock(settings, token):
+ return _set_receipt_locked(settings, token, **changes)
+
+
+def _service_for(settings: LightweightApiSettings) -> LightweightExtensionService:
+ return LightweightExtensionService(settings.store_root)
+
+
+def _logs_for(record: dict[str, Any], token: str) -> list[str]:
+ operation = (record.get("operations") or {}).get(token) or {}
+ run_id = operation.get("run_id")
+ baseline_action = str(operation.get("action") or "") in {"upload", "rebuild"}
+ candidates: list[dict[str, Any]] = []
+ baseline = record.get("baseline")
+ if (
+ baseline_action
+ and isinstance(baseline, dict)
+ and Path(str(baseline.get("run_dir") or "")).name == run_id
+ ):
+ candidates.append(baseline)
+ else:
+ candidates.extend(item for item in record.get("validation_runs", []) if isinstance(item, dict))
+ candidates.extend(item for item in record.get("runtime_runs", []) if isinstance(item, dict))
+ logs: set[str] = set()
+ for item in candidates:
+ if run_id and not baseline_action and item.get("run_id") != run_id:
+ continue
+ for value in (item.get("logs") or {}).values():
+ if value:
+ logs.add(str(value))
+ return sorted(logs)
+
+
+def _operation_run(record: dict[str, Any], operation: dict[str, Any]) -> tuple[dict[str, Any] | None, str]:
+ run_id = operation.get("run_id") or (operation.get("response") or {}).get("run_id")
+ action = str(operation.get("action") or "")
+ if action in {"upload", "rebuild"}:
+ baseline = record.get("baseline")
+ if not isinstance(baseline, dict) or Path(str(baseline.get("run_dir") or "")).name != run_id:
+ return None, "baseline"
+ return baseline, "baseline"
+ collection_name = "runtime_runs" if action == "run" else "validation_runs"
+ for run in record.get(collection_name, []):
+ if isinstance(run, dict) and run.get("run_id") == run_id:
+ return run, "runtime" if action == "run" else "validation"
+ return None, "runtime" if action == "run" else "validation"
+
+
+def _run_error(record: dict[str, Any], token: str, run: dict[str, Any], phase: str) -> dict[str, Any]:
+ details = run.get("error") if isinstance(run.get("error"), dict) else {}
+ binding = run.get("binding") if isinstance(run.get("binding"), dict) else {}
+ binding_error = binding.get("error") if isinstance(binding.get("error"), dict) else {}
+ message = (
+ details.get("message")
+ or binding_error.get("message")
+ or ("runtime binding was not resolved" if binding.get("status") != "resolved" else "execution did not satisfy the output contract")
+ )
+ return _error(str(details.get("phase") or phase), message, logs=_logs_for(record, token))
+
+
+def _current_operation_error(
+ service: LightweightExtensionService, *, extension_id: str, token: str, result: dict[str, Any]
+) -> dict[str, Any] | None:
+ record = service.get(extension_id)
+ operation = (record.get("operations") or {}).get(token)
+ error = record.get("error")
+ if not isinstance(operation, dict) or not isinstance(error, dict):
+ return None
+ operation_status = (operation.get("response") or {}).get("status") or record.get("status")
+ if operation_status != result.get("status"):
+ return None
+ return _error(
+ str(error.get("phase") or operation.get("action") or "operation"),
+ str(error.get("message") or "operation failed"),
+ logs=_logs_for(record, token),
+ )
+
+
+def _operation_view(
+ settings: LightweightApiSettings, *, token: str, service_factory: Callable[[LightweightApiSettings], LightweightExtensionService]
+) -> dict[str, Any]:
+ receipt = _read_json(_receipt_path(settings, token))
+ if receipt is None:
+ raise ValueError("operation does not exist")
+ result = dict(receipt)
+ try:
+ record = service_factory(settings).get(str(receipt["extension_id"]))
+ except ValueError:
+ return result
+ operation = (record.get("operations") or {}).get(token)
+ if isinstance(operation, dict):
+ result.update(operation.get("response") or {})
+ result["state"] = operation.get("state", result.get("state"))
+ result["status"] = (
+ (operation.get("response") or {}).get("status")
+ or record.get("status")
+ or result.get("status")
+ )
+ result["extension_status"] = record.get("status")
+ run, phase = _operation_run(record, operation)
+ if isinstance(run, dict) and (
+ run.get("success") is False or run.get("contract_passed") is False
+ ):
+ result["outcome"] = "failed"
+ result["error"] = _run_error(record, token, run, phase)
+ result["logs"] = result["error"]["logs"]
+ elif isinstance(run, dict):
+ result["outcome"] = "succeeded"
+ elif result.get("error"):
+ result["outcome"] = "failed"
+ result["logs"] = list((result.get("error") or {}).get("logs") or [])
+ elif operation.get("action") not in {"upload", "rebuild"} and record.get("error"):
+ result["outcome"] = "failed"
+ result["error"] = record["error"]
+ result["logs"] = _logs_for(record, token)
+ return result
+
+
+def _error(phase: str, exc: BaseException | str, *, logs: list[str] | None = None) -> dict[str, Any]:
+ return {"phase": phase, "message": str(exc), "logs": logs or []}
+
+
+def _http_error(status_code: int, phase: str, exc: BaseException | str) -> HTTPException:
+ return HTTPException(status_code=status_code, detail=_error(phase, exc))
+
+
+def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]:
+ """Worker entrypoint. It intentionally owns no Celery configuration."""
+
+ settings = LightweightApiSettings.from_environment()
+ action = str(payload["action"])
+ extension_id = _safe_component(str(payload["extension_id"]), name="extension_id")
+ token = _safe_component(str(payload["operation_token"]), name="operation_token")
+ _set_receipt(settings, token, state="running", status="running")
+ service = _service_for(settings)
+ try:
+ if action == "upload":
+ stage = settings.store_root / "staging" / token
+ result = service.upload(
+ extension_id=extension_id,
+ raw_code=stage / "raw_code",
+ raw_data=stage / "raw_test_data",
+ original_command=str(payload["original_command"]),
+ operation_token=token,
+ )
+ elif action == "rebuild":
+ result = service.rebuild(extension_id, operation_token=token)
+ elif action == "retry_semantic":
+ result = service.retry_semantic(extension_id, operation_token=token)
+ elif action == "revise":
+ result = service.revise(
+ extension_id,
+ feedback=str(payload["feedback"]),
+ revision_target=str(payload["revision_target"]),
+ operation_token=token,
+ )
+ elif action == "confirm":
+ result = service.confirm(extension_id, operation_token=token)
+ elif action == "validate":
+ result = service.validate(extension_id, operation_token=token)
+ elif action == "review":
+ result = service.review_result(extension_id, operation_token=token)
+ elif action == "publish":
+ result = service.publish(extension_id, operation_token=token)
+ elif action == "run":
+ result = service.run(
+ extension_id,
+ input_overrides={
+ key: _runtime_input_path(settings, str(payload["run_job_id"]), value)
+ for key, value in payload.get("input_overrides", {}).items()
+ },
+ run_job_id=str(payload["run_job_id"]),
+ input_root=settings.input_root,
+ workspace_root=settings.workspace_root,
+ parameter_overrides=payload.get("parameter_overrides") or {},
+ explicit_prefix=payload.get("explicit_prefix"),
+ operation_token=token,
+ )
+ else:
+ raise ValueError(f"unsupported lightweight operation: {action}")
+ except BaseException as exc:
+ receipt = _set_receipt(
+ settings, token, state="failed", status="failed", error=_error(action, exc)
+ )
+ return receipt
+ changes: dict[str, Any] = {
+ "state": "completed",
+ "status": result.get("status"),
+ "result": result,
+ }
+ try:
+ current_error = _current_operation_error(
+ service, extension_id=extension_id, token=token, result=result
+ )
+ except ValueError:
+ current_error = None
+ if current_error is not None:
+ changes["error"] = current_error
+ receipt = _set_receipt(settings, token, **changes)
+ return receipt
+
+
+def _default_enqueue(payload: dict[str, Any]) -> str:
+ from tasks import execute_lightweight_extension_operation
+
+ return str(execute_lightweight_extension_operation.apply_async(args=[payload]).id)
+
+
+def create_lightweight_extension_router(
+ *,
+ settings: LightweightApiSettings | None = None,
+ service_factory: Callable[[LightweightApiSettings], LightweightExtensionService] = _service_for,
+ enqueue: Callable[[dict[str, Any]], str] = _default_enqueue,
+) -> APIRouter:
+ settings = settings or LightweightApiSettings.from_environment()
+ router = APIRouter(prefix="/api/lightweight-extension-stages", tags=["lightweight-extension-stages"])
+
+ def submit(extension_id: str, action: str, token: str, **payload: Any) -> dict[str, Any]:
+ extension_id = _safe_component(extension_id, name="extension_id")
+ token = _safe_component(token, name="operation_token")
+ receipt, created = _reserve_operation(
+ settings, extension_id=extension_id, action=action, token=token
+ )
+ if created:
+ try:
+ celery_task_id = enqueue(
+ {
+ "extension_id": extension_id,
+ "action": action,
+ "operation_token": token,
+ **payload,
+ }
+ )
+ except BaseException as exc:
+ receipt = _set_receipt(
+ settings, token, state="failed", status="failed", error=_error("queue", exc)
+ )
+ else:
+ receipt = _set_receipt(settings, token, celery_task_id=celery_task_id)
+ return receipt
+
+ @router.post("/upload", status_code=202)
+ async def upload(
+ extension_id: str = Form(...),
+ original_command: str | None = Form(None),
+ operation_token: str = Form(...),
+ raw_code_files: list[UploadFile] | None = File(None),
+ raw_data_files: list[UploadFile] | None = File(None),
+ raw_data_paths: str | None = Form(None),
+ ) -> dict[str, Any]:
+ temporary_stage: Path | None = None
+ try:
+ token = _safe_component(operation_token, name="operation_token")
+ safe_id = _safe_component(extension_id, name="extension_id")
+ staged_files = [
+ ("raw_code", _relative_path(item.filename or ""), item)
+ for item in (raw_code_files or [])
+ ] + [
+ ("raw_test_data", _relative_path(item.filename or ""), item)
+ for item in (raw_data_files or [])
+ ]
+ server_data_files = _server_data_files(raw_data_paths)
+ data_destinations = {
+ relative.as_posix()
+ for kind, relative, _item in staged_files
+ if kind == "raw_test_data"
+ }
+ for relative, _source in server_data_files:
+ key = relative.as_posix()
+ if key in data_destinations:
+ raise ValueError(f"测试数据文件名冲突:{key}")
+ data_destinations.add(key)
+ if not data_destinations:
+ raise ValueError("请上传测试数据或填写测试数据服务器路径")
+ staging_root = settings.store_root / "staging"
+ staging_root.mkdir(parents=True, exist_ok=True)
+ temporary_stage = Path(
+ tempfile.mkdtemp(prefix=f".{token}-{uuid4().hex}-", dir=staging_root)
+ )
+ raw_code = temporary_stage / "raw_code"
+ raw_data = temporary_stage / "raw_test_data"
+ raw_code.mkdir()
+ raw_data.mkdir()
+ for kind, relative, item in staged_files:
+ destination = temporary_stage / kind / relative
+ await _copy_upload_file(item, destination)
+ for relative, source in server_data_files:
+ destination = raw_data / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.symlink_to(source)
+ normalized_entry = normalize_raw_package(
+ explicit_command=original_command or "",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ saved_code=sorted(path for path in raw_code.rglob("*") if path.is_file()),
+ )
+ if normalized_entry["requires_user_confirmation"]:
+ warnings = "; ".join(normalized_entry["normalization_warnings"])
+ scripts = ", ".join(normalized_entry["detected_scripts"])
+ detail = warnings or "uploaded code has no unambiguous entry command"
+ if scripts:
+ detail += f"; detected scripts: {scripts}"
+ raise ValueError(f"{detail}; provide original_command")
+ normalized_command = str(normalized_entry["entry_command"]).strip()
+ with _token_lock(settings, token):
+ existing = _read_json(_receipt_path(settings, token))
+ if existing is not None:
+ if existing.get("extension_id") != safe_id or existing.get("action") != "upload":
+ raise ValueError("operation_token is already bound to another action")
+ receipt = existing
+ else:
+ stage = staging_root / token
+ if stage.exists():
+ shutil.rmtree(stage)
+ temporary_stage.replace(stage)
+ temporary_stage = None
+ receipt = _queued_receipt(extension_id=safe_id, action="upload", token=token)
+ _write_json(_receipt_path(settings, token), receipt)
+ try:
+ celery_task_id = enqueue(
+ {
+ "extension_id": safe_id,
+ "action": "upload",
+ "operation_token": token,
+ "original_command": normalized_command,
+ }
+ )
+ except BaseException as exc:
+ receipt = _set_receipt_locked(
+ settings, token, state="failed", status="failed", error=_error("queue", exc)
+ )
+ else:
+ receipt = _set_receipt_locked(settings, token, celery_task_id=celery_task_id)
+ return receipt
+ except ValueError as exc:
+ raise _http_error(400, "upload", exc) from exc
+ finally:
+ if temporary_stage is not None:
+ shutil.rmtree(temporary_stage, ignore_errors=True)
+
+ @router.get("/")
+ async def list_extensions() -> dict[str, Any]:
+ extensions: list[dict[str, str]] = []
+ for record_path in sorted((settings.store_root / "extensions").glob("*/extension.json")):
+ record = _read_json(record_path)
+ if isinstance(record, dict) and record.get("extension_id"):
+ extensions.append(
+ {
+ "extension_id": str(record["extension_id"]),
+ "status": str(record.get("status") or ""),
+ }
+ )
+ return {"extensions": extensions}
+
+ @router.post("/{extension_id}/rebuild", status_code=202)
+ async def rebuild(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(submit, extension_id, "rebuild", req.operation_token)
+
+ @router.post("/{extension_id}/retry-semantic", status_code=202)
+ async def retry_semantic(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(
+ submit, extension_id, "retry_semantic", req.operation_token
+ )
+
+ @router.post("/{extension_id}/revise", status_code=202)
+ async def revise(extension_id: str, req: _ReviseRequest) -> dict[str, Any]:
+ return _submit_response(
+ submit, extension_id, "revise", req.operation_token,
+ feedback=req.feedback, revision_target=req.revision_target,
+ )
+
+ @router.post("/{extension_id}/confirm", status_code=202)
+ async def confirm(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(submit, extension_id, "confirm", req.operation_token)
+
+ @router.post("/{extension_id}/validate", status_code=202)
+ async def validate(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(submit, extension_id, "validate", req.operation_token)
+
+ @router.post("/{extension_id}/review", status_code=202)
+ async def review(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(submit, extension_id, "review", req.operation_token)
+
+ @router.post("/{extension_id}/publish", status_code=202)
+ async def publish(extension_id: str, req: _TokenRequest) -> dict[str, Any]:
+ return _submit_response(submit, extension_id, "publish", req.operation_token)
+
+ @router.post("/{extension_id}/run", status_code=202)
+ async def run(extension_id: str, req: _RunRequest) -> dict[str, Any]:
+ return _submit_response(
+ submit, extension_id, "run", req.operation_token, run_job_id=req.run_job_id,
+ input_overrides=req.input_overrides, parameter_overrides=req.parameter_overrides,
+ explicit_prefix=req.explicit_prefix,
+ )
+
+ @router.get("/{extension_id}/help")
+ async def help(extension_id: str) -> dict[str, Any]:
+ try:
+ return service_factory(settings).help(_safe_component(extension_id, name="extension_id"))
+ except ValueError as exc:
+ raise _http_error(404, "help", exc) from exc
+
+ @router.post("/intent")
+ async def intent(req: _IntentRequest) -> dict[str, Any]:
+ if not req.extension_id:
+ return {
+ "intent": req.message,
+ "usage": "上传可执行代码、测试数据和原始命令,核对识别出的输入与参数;验证结果后授权发布。发布后可用自然语言指定已发布拓展分析、输入文件和原命令已有参数来运行,也可随时询问某个工具的帮助。",
+ }
+ try:
+ return {"intent": req.message, "help": service_factory(settings).help(req.extension_id)}
+ except ValueError as exc:
+ raise _http_error(404, "intent", exc) from exc
+
+ @router.get("/operations/{operation_token}")
+ async def operation(operation_token: str) -> dict[str, Any]:
+ try:
+ return _operation_view(settings, token=_safe_component(operation_token, name="operation_token"), service_factory=service_factory)
+ except ValueError as exc:
+ raise _http_error(404, "operation", exc) from exc
+
+ @router.post("/{extension_id}/operations/{operation_token}/stop")
+ async def stop(extension_id: str, operation_token: str) -> dict[str, Any]:
+ try:
+ result = service_factory(settings).stop(
+ _safe_component(extension_id, name="extension_id"),
+ operation_token=_safe_component(operation_token, name="operation_token"),
+ )
+ return result
+ except ValueError as exc:
+ raise _http_error(400, "stop", exc) from exc
+
+ @router.get("/{extension_id}/results")
+ async def results(extension_id: str) -> dict[str, Any]:
+ try:
+ record = service_factory(settings).get(_safe_component(extension_id, name="extension_id"))
+ return {
+ "extension_id": extension_id,
+ "validation_runs": record.get("validation_runs", []),
+ "runtime_runs": record.get("runtime_runs", []),
+ }
+ except ValueError as exc:
+ raise _http_error(404, "results", exc) from exc
+
+ @router.get("/{extension_id}/files")
+ async def result_file(extension_id: str, run_id: str, path: str):
+ try:
+ if _relative_path(path).as_posix() != path:
+ raise ValueError("path must be a safe relative output path")
+ safe_id = _safe_component(extension_id, name="extension_id")
+ safe_run = _safe_component(run_id, name="run_id")
+ record = service_factory(settings).get(safe_id)
+ run = next((item for item in record.get("validation_runs", []) if item.get("run_id") == safe_run), None)
+ kind = "validation"
+ if run is None:
+ run = next((item for item in record.get("runtime_runs", []) if item.get("run_id") == safe_run), None)
+ kind = "runtime"
+ if not isinstance(run, dict):
+ raise ValueError("run does not exist")
+ selected = next(
+ (item for item in (run.get("projection") or {}).get("files", []) if item.get("path") == path),
+ None,
+ )
+ if not isinstance(selected, dict):
+ raise ValueError("file is not a listed result output")
+ output = Path(str(selected.get("absolute_path") or "")).resolve()
+ if kind == "validation":
+ output_root = (
+ settings.store_root / "extensions" / safe_id / "runs" / safe_run / "outputs"
+ ).resolve()
+ else:
+ relative = output.relative_to(settings.workspace_root.resolve())
+ if len(relative.parts) < 6:
+ raise ValueError("runtime result file is outside its run output directory")
+ job_id, marker, found_id, found_run, outputs, *suffix = relative.parts
+ if (
+ marker != "extension_runs"
+ or found_id != safe_id
+ or found_run != safe_run
+ or outputs != "outputs"
+ or not suffix
+ ):
+ raise ValueError("runtime result file is outside its run output directory")
+ output_root = (
+ settings.workspace_root
+ / _safe_component(job_id, name="run_job_id")
+ / marker
+ / safe_id
+ / safe_run
+ / outputs
+ ).resolve()
+ if output != output_root / path or not output.is_file():
+ raise ValueError("result file does not exist")
+ return FileResponse(str(output), filename=output.name)
+ except ValueError as exc:
+ raise _http_error(400, "files", exc) from exc
+
+ return router
+
+
+def _submit_response(
+ submit: Callable[..., dict[str, Any]], extension_id: str, action: str, token: str, **payload: Any
+) -> dict[str, Any]:
+ try:
+ return submit(extension_id, action, token, **payload)
+ except ValueError as exc:
+ raise _http_error(400, action, exc) from exc
diff --git a/gfa_core/extension_stage_lightweight/command_model.py b/gfa_core/extension_stage_lightweight/command_model.py
new file mode 100644
index 000000000..36e729652
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/command_model.py
@@ -0,0 +1,454 @@
+"""Build a command model from tokens and observed execution files only."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import re
+import shlex
+from typing import Any
+
+
+_REDIRECTS = {">", ">>", "1>", "1>>", "2>", "2>>"}
+_CONTROL_TOKENS = _REDIRECTS | {"<", "<<", "|", "||", "&&", ";"}
+_SHELL_CONTROL_WORDS = {
+ "case",
+ "do",
+ "done",
+ "elif",
+ "else",
+ "esac",
+ "exit",
+ "fi",
+ "for",
+ "if",
+ "set",
+ "then",
+ "while",
+}
+
+
+def _normalise_path(value: str, work_dir: Path) -> str:
+ path = Path(value)
+ if path.is_absolute():
+ try:
+ return path.resolve().relative_to(work_dir.resolve()).as_posix()
+ except ValueError:
+ return path.as_posix()
+ normalised = path.as_posix()
+ while normalised.startswith("./"):
+ normalised = normalised[2:]
+ return normalised
+
+
+def _logical_shell_commands(text: str) -> list[str]:
+ commands: list[str] = []
+ pending = ""
+ for raw_line in text.splitlines():
+ stripped = raw_line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ if stripped.endswith("\\"):
+ pending += stripped[:-1].rstrip() + " "
+ continue
+ logical = (pending + stripped).strip()
+ pending = ""
+ if logical:
+ commands.append(logical)
+ if pending.strip():
+ commands.append(pending.strip())
+ return commands
+
+
+def _split_shell_chain(command: str) -> list[str]:
+ segments: list[str] = []
+ buffer: list[str] = []
+ quote: str | None = None
+ escaped = False
+ i = 0
+ while i < len(command):
+ char = command[i]
+ if escaped:
+ buffer.append(char)
+ escaped = False
+ i += 1
+ continue
+ if char == "\\":
+ buffer.append(char)
+ escaped = True
+ i += 1
+ continue
+ if quote:
+ buffer.append(char)
+ if char == quote:
+ quote = None
+ i += 1
+ continue
+ if char in {"'", '"'}:
+ quote = char
+ buffer.append(char)
+ i += 1
+ continue
+ is_boundary = char == ";" or (
+ char in {"|", "&"}
+ and i + 1 < len(command)
+ and (char == "|" or command[i + 1] == char)
+ )
+ if is_boundary:
+ segment = "".join(buffer).strip()
+ if segment:
+ segments.append(segment)
+ buffer = []
+ if i + 1 < len(command) and command[i + 1] == char:
+ i += 1
+ i += 1
+ continue
+ buffer.append(char)
+ i += 1
+ segment = "".join(buffer).strip()
+ if segment:
+ segments.append(segment)
+ return segments or [command]
+
+
+def _substitute_shell_variables(command: str, variables: dict[str, str]) -> str:
+ result: list[str] = []
+ in_single_quote = False
+ i = 0
+ while i < len(command):
+ char = command[i]
+ if char == "'":
+ in_single_quote = not in_single_quote
+ result.append(char)
+ i += 1
+ continue
+ if char != "$" or in_single_quote:
+ result.append(char)
+ i += 1
+ continue
+ if i + 1 < len(command) and command[i + 1] == "{":
+ end = command.find("}", i + 2)
+ if end != -1:
+ key = command[i + 2 : end]
+ if key in variables:
+ result.append(variables[key])
+ i = end + 1
+ continue
+ match = re.match(r"[A-Za-z_][A-Za-z0-9_]*|[0-9]+", command[i + 1 :])
+ if match:
+ key = match.group(0)
+ if key in variables:
+ result.append(variables[key])
+ i += len(key) + 1
+ continue
+ result.append(char)
+ i += 1
+ return "".join(result)
+
+
+def _prepare_script_commands(text: str, positional_args: list[str]) -> list[str]:
+ variables = {str(index): value for index, value in enumerate(positional_args, 1)}
+ commands: list[str] = []
+ for logical in _logical_shell_commands(text):
+ for segment in _split_shell_chain(logical):
+ expanded = _substitute_shell_variables(segment, variables)
+ assignment = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)=(.*)", expanded)
+ if assignment:
+ values = shlex.split(assignment.group(2))
+ variables[assignment.group(1)] = values[0] if values else ""
+ continue
+ try:
+ segment_tokens = shlex.split(expanded)
+ except ValueError:
+ commands.append(expanded)
+ continue
+ if not segment_tokens or segment_tokens[0] in _SHELL_CONTROL_WORDS:
+ continue
+ commands.append(expanded)
+ return commands
+
+
+def _entry_commands(
+ command: str, work_dir: Path, code_files: set[str]
+) -> tuple[list[str], list[str]]:
+ try:
+ tokens = shlex.split(command)
+ except ValueError:
+ return [command], []
+ for source_index, token in enumerate(tokens):
+ relative = _normalise_path(token, work_dir)
+ if relative not in code_files:
+ continue
+ path = work_dir / relative
+ if path.suffix in {".sh", ".bash"} or tokens[0] in {"bash", "sh"}:
+ script_commands = _prepare_script_commands(
+ path.read_text(encoding="utf-8", errors="replace"),
+ tokens[source_index + 1 :],
+ )
+ return script_commands, [relative]
+ return _split_shell_chain(command), []
+
+
+def _matching_outputs(value: str, outputs: set[str], work_dir: Path) -> list[str]:
+ relative = _normalise_path(value, work_dir)
+ if relative in outputs:
+ return [relative]
+ return sorted(
+ output for output in outputs if output.startswith(relative + ".")
+ )
+
+
+def _append_unique(items: list[dict[str, Any]], item: dict[str, Any]) -> None:
+ identity = (item.get("path"), item.get("flag"), item.get("token_index"))
+ if all(
+ (existing.get("path"), existing.get("flag"), existing.get("token_index"))
+ != identity
+ for existing in items
+ ):
+ items.append(item)
+
+
+def _outer_quoted_token_indexes(command: str, tokens: list[str]) -> set[int]:
+ """Return fully quoted token indexes when POSIX/non-POSIX splits align."""
+
+ try:
+ source_tokens = shlex.split(command, posix=False)
+ except ValueError:
+ return set()
+ if len(source_tokens) != len(tokens):
+ return set()
+ for source_token, token in zip(source_tokens, tokens):
+ try:
+ if shlex.split(source_token, posix=True) != [token]:
+ return set()
+ except ValueError:
+ return set()
+ return {
+ index
+ for index, source_token in enumerate(source_tokens)
+ if len(source_token) >= 2
+ and source_token[0] in {"'", '"'}
+ and source_token[-1] == source_token[0]
+ }
+
+
+def build_command_model(
+ *,
+ command: str,
+ work_dir: Path,
+ before_files: dict[str, dict[str, object]],
+ output_files: list[str],
+ code_files: list[str],
+) -> dict[str, Any]:
+ """Classify explicit tokens using baseline file evidence.
+
+ No executable name or biological file format is used to make the decision.
+ """
+
+ work_dir = Path(work_dir)
+ before = set(before_files)
+ outputs = set(output_files)
+ code = set(code_files)
+ commands, source_scripts = _entry_commands(command, work_dir, code)
+ models: list[dict[str, Any]] = []
+ assigned_outputs: set[str] = set()
+ produced_references: set[str] = set()
+
+ for index, logical_command in enumerate(commands, start=1):
+ try:
+ tokens = shlex.split(logical_command)
+ except ValueError as exc:
+ models.append(
+ {
+ "index": index,
+ "command": logical_command,
+ "tool": None,
+ "parse_error": str(exc),
+ "source_files": [],
+ "inputs": [],
+ "arguments": [],
+ "parameters": [],
+ "outputs": [],
+ }
+ )
+ continue
+ if not tokens:
+ continue
+
+ model: dict[str, Any] = {
+ "index": index,
+ "command": logical_command,
+ "tool": tokens[0],
+ "source_files": [],
+ "inputs": [],
+ "arguments": [],
+ "parameters": [],
+ "outputs": [],
+ }
+ command_outputs: set[str] = set()
+ outer_quoted_indexes = _outer_quoted_token_indexes(logical_command, tokens)
+ ambiguous_positional_indexes: set[int] = set()
+ i = 1
+ positional_index = 0
+ while i < len(tokens):
+ token = tokens[i]
+ if token in _REDIRECTS and i + 1 < len(tokens):
+ value = _normalise_path(tokens[i + 1], work_dir)
+ matched = _matching_outputs(value, outputs, work_dir)
+ command_outputs.update(matched or ([value] if value in outputs else []))
+ produced_references.add(value)
+ i += 2
+ continue
+ if token in _CONTROL_TOKENS:
+ i += 1
+ continue
+
+ if token.startswith("-") and token != "-":
+ if (
+ "=" not in token
+ and i + 1 in outer_quoted_indexes
+ and i + 2 < len(tokens)
+ ):
+ candidate = _normalise_path(tokens[i + 1], work_dir)
+ candidate_is_reference = (
+ candidate in before
+ or candidate in code
+ or candidate in produced_references
+ or bool(_matching_outputs(candidate, outputs, work_dir))
+ )
+ later_has_input_evidence = any(
+ (
+ _normalise_path(later, work_dir) in before
+ and _normalise_path(later, work_dir) not in code
+ )
+ or _normalise_path(later, work_dir) in produced_references
+ for later in tokens[i + 2 :]
+ )
+ if not candidate_is_reference and later_has_input_evidence:
+ argument = {
+ "kind": "switch",
+ "flag": token,
+ "value": token,
+ "token_index": i,
+ "grouping_confidence": "ambiguous",
+ }
+ model["arguments"].append(argument)
+ model["parameters"].append(dict(argument))
+ ambiguous_positional_indexes.add(i + 1)
+ i += 1
+ continue
+ if "=" in token:
+ flag, raw_value = token.split("=", 1)
+ value_index = i
+ elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-") and tokens[i + 1] not in _CONTROL_TOKENS:
+ flag, raw_value = token, tokens[i + 1]
+ value_index = i + 1
+ else:
+ argument = {
+ "kind": "switch",
+ "flag": token,
+ "value": token,
+ "token_index": i,
+ }
+ model["arguments"].append(argument)
+ model["parameters"].append(dict(argument))
+ i += 1
+ continue
+
+ value = _normalise_path(raw_value, work_dir)
+ value_role = "value"
+ if value in before and value not in code:
+ value_role = "input"
+ _append_unique(
+ model["inputs"],
+ {
+ "path": value,
+ "source": "baseline_input",
+ "flag": flag,
+ "token_index": value_index,
+ },
+ )
+ elif value in produced_references:
+ value_role = "input"
+ _append_unique(
+ model["inputs"],
+ {
+ "path": value,
+ "source": "generated_input",
+ "flag": flag,
+ "token_index": value_index,
+ },
+ )
+ else:
+ matched = _matching_outputs(value, outputs, work_dir)
+ if matched:
+ value_role = "output_reference"
+ command_outputs.update(matched)
+ produced_references.add(value)
+
+ argument = {
+ "kind": "flag_value",
+ "flag": flag,
+ "value": raw_value,
+ "token_index": i,
+ "value_role": value_role,
+ }
+ model["arguments"].append(argument)
+ if value_role != "input":
+ model["parameters"].append(dict(argument))
+ i += 1 if "=" in token else 2
+ continue
+
+ relative = _normalise_path(token, work_dir)
+ if relative in code:
+ if relative not in model["source_files"]:
+ model["source_files"].append(relative)
+ elif relative in before:
+ _append_unique(
+ model["inputs"],
+ {
+ "path": relative,
+ "source": "baseline_input",
+ "flag": None,
+ "token_index": i,
+ },
+ )
+ elif relative in produced_references:
+ _append_unique(
+ model["inputs"],
+ {
+ "path": relative,
+ "source": "generated_input",
+ "flag": None,
+ "token_index": i,
+ },
+ )
+ else:
+ matched = _matching_outputs(relative, outputs, work_dir)
+ value_role = "output_reference" if matched else "value"
+ command_outputs.update(matched)
+ if matched:
+ produced_references.add(relative)
+ parameter = {
+ "kind": "positional",
+ "flag": None,
+ "value": token,
+ "token_index": i,
+ "position": positional_index,
+ "value_role": value_role,
+ }
+ if i in ambiguous_positional_indexes:
+ parameter["grouping_confidence"] = "ambiguous"
+ model["parameters"].append(parameter)
+ positional_index += 1
+ i += 1
+
+ model["outputs"] = sorted(command_outputs)
+ assigned_outputs.update(command_outputs)
+ models.append(model)
+
+ return {
+ "entry_command": command,
+ "source_scripts": source_scripts,
+ "commands": models,
+ "unmatched_outputs": sorted(outputs - assigned_outputs),
+ }
diff --git a/gfa_core/extension_stage_lightweight/confirmation.py b/gfa_core/extension_stage_lightweight/confirmation.py
new file mode 100644
index 000000000..325825fc0
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/confirmation.py
@@ -0,0 +1,181 @@
+"""Bind user confirmation to one locked fact set and semantic candidate."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+import hashlib
+import json
+from typing import Any
+
+from .semantic_model import SemanticCandidateError, validate_semantic_candidate
+
+
+SEMANTIC_REVISION_TARGETS = {
+ "semantic",
+ "function_summary",
+ "inputs.role",
+ "inputs.description",
+ "inputs.replaceability",
+ "parameters.display_name",
+ "parameters.description",
+ "outputs.description",
+ "outputs.result_role",
+}
+FACT_DISPUTE_TARGETS = {
+ "execution_facts.original_command",
+ "execution_facts.command",
+ "execution_facts.path",
+ "execution_facts.flag",
+ "execution_facts.value",
+ "execution_facts.default_value",
+ "execution_facts.observed_output",
+}
+
+
+def _sha256_json(value: Any) -> str:
+ encoded = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _applied_revision_diff(
+ repair_history: list[dict[str, Any]] | None,
+) -> list[dict[str, Any]]:
+ return [
+ deepcopy(change)
+ for attempt in repair_history or []
+ if attempt.get("status") == "success"
+ for change in attempt.get("diff", [])
+ ]
+
+
+def create_confirmation(
+ *,
+ execution_facts: dict[str, Any],
+ candidate: dict[str, Any],
+ repair_history: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ """Create a pending decision bound to the current facts and candidate."""
+
+ errors = validate_semantic_candidate(execution_facts, candidate)
+ if errors:
+ raise SemanticCandidateError(errors)
+ facts_sha256 = execution_facts["facts_sha256"]
+ candidate_sha256 = _sha256_json(candidate)
+ binding_sha256 = _sha256_json(
+ {
+ "facts_sha256": facts_sha256,
+ "candidate_sha256": candidate_sha256,
+ }
+ )
+ return {
+ "confirmation_id": f"confirmation_{binding_sha256[:16]}",
+ "status": "pending",
+ "accepted": False,
+ "facts_sha256": facts_sha256,
+ "candidate_sha256": candidate_sha256,
+ "candidate_version": f"semantic_{candidate_sha256[:16]}",
+ "summary": {
+ "function_summary": candidate["function_summary"],
+ "input_count": len(candidate["inputs"]),
+ "parameter_count": len(candidate["parameters"]),
+ "output_count": len(candidate["outputs"]),
+ "revision_diff": _applied_revision_diff(repair_history),
+ },
+ }
+
+
+def _stale_reason(
+ *,
+ confirmation: dict[str, Any],
+ execution_facts: dict[str, Any],
+ candidate: dict[str, Any],
+) -> str | None:
+ if confirmation.get("facts_sha256") != execution_facts.get("facts_sha256"):
+ return "facts_changed"
+ if confirmation.get("candidate_sha256") != _sha256_json(candidate):
+ return "candidate_changed"
+ return None
+
+
+def apply_confirmation_action(
+ *,
+ confirmation: dict[str, Any],
+ action: str,
+ execution_facts: dict[str, Any],
+ candidate: dict[str, Any],
+ feedback: str | None = None,
+ revision_target: str | None = None,
+) -> dict[str, Any]:
+ """Apply one user decision without mutating the supplied confirmation."""
+
+ result = deepcopy(confirmation)
+ result["accepted"] = False
+ stale_reason = _stale_reason(
+ confirmation=confirmation,
+ execution_facts=execution_facts,
+ candidate=candidate,
+ )
+ if stale_reason:
+ result["status"] = "stale"
+ result["stale_reason"] = stale_reason
+ return result
+
+ if action == "confirm":
+ result["status"] = "confirmed"
+ result["accepted"] = True
+ return result
+ if action == "cancel":
+ result["status"] = "cancelled"
+ return result
+ if action != "revise":
+ raise ValueError(f"unknown confirmation action: {action}")
+
+ normalized_feedback = (feedback or "").strip()
+ if not normalized_feedback:
+ raise ValueError("revise requires non-empty feedback")
+ if revision_target in SEMANTIC_REVISION_TARGETS:
+ result.update(
+ {
+ "status": "revision_requested",
+ "route": "semantic_repair",
+ "feedback": normalized_feedback,
+ "revision_target": revision_target,
+ }
+ )
+ return result
+ if revision_target in FACT_DISPUTE_TARGETS:
+ result.update(
+ {
+ "status": "fact_dispute",
+ "route": "recollect_execution_evidence",
+ "feedback": normalized_feedback,
+ "revision_target": revision_target,
+ }
+ )
+ return result
+ raise ValueError(f"unknown revision target: {revision_target}")
+
+
+def is_candidate_accepted(
+ *,
+ confirmation: dict[str, Any],
+ execution_facts: dict[str, Any],
+ candidate: dict[str, Any],
+) -> bool:
+ """Return true only for a confirmed decision with an unchanged binding."""
+
+ return (
+ confirmation.get("status") == "confirmed"
+ and confirmation.get("accepted") is True
+ and _stale_reason(
+ confirmation=confirmation,
+ execution_facts=execution_facts,
+ candidate=candidate,
+ )
+ is None
+ )
diff --git a/gfa_core/extension_stage_lightweight/converter.py b/gfa_core/extension_stage_lightweight/converter.py
new file mode 100644
index 000000000..77ac916bc
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/converter.py
@@ -0,0 +1,207 @@
+"""Create and replay a minimal deterministic extension specification."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+import shlex
+from typing import Any
+
+from .command_model import build_command_model
+from .evidence import run_raw_baseline
+
+
+def _output_files(evidence: dict[str, Any]) -> list[str]:
+ return sorted(
+ set(evidence["outputs"]["new_files"])
+ | set(evidence["outputs"]["modified_files"])
+ )
+
+
+def _fact_id(step_id: str, kind: str, item: dict[str, Any]) -> str:
+ token_index = item.get("token_index")
+ if not isinstance(token_index, int):
+ raise ValueError(f"{kind} fact is missing token_index in {step_id}")
+ return f"{step_id}:{kind}:{token_index:03d}"
+
+
+def _facts_sha256(facts: dict[str, Any]) -> str:
+ payload = json.dumps(
+ facts,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def build_execution_facts(evidence: dict[str, Any]) -> dict[str, Any]:
+ """Build an immutable, ID-addressable view of observed execution facts."""
+
+ output_files = _output_files(evidence)
+ command_model = build_command_model(
+ command=evidence["command"],
+ work_dir=Path(evidence["work_dir"]),
+ before_files=evidence["before_files"],
+ output_files=output_files,
+ code_files=evidence["staged"]["code_files"],
+ )
+
+ code_files = list(evidence["staged"]["code_files"])
+ data_files = list(evidence["staged"]["data_files"])
+ inventory: dict[str, dict[str, Any]] = {}
+ for source, paths in (("raw_code", code_files), ("raw_test_data", data_files)):
+ for path in paths:
+ inventory[path] = {
+ **deepcopy(evidence["before_files"][path]),
+ "source": source,
+ }
+
+ commands = deepcopy(command_model["commands"])
+ producers: dict[str, list[str]] = {path: [] for path in output_files}
+ for command in commands:
+ step_id = f"step_{command['index']:03d}"
+ command["step_id"] = step_id
+ for item in command["inputs"]:
+ item["fact_id"] = _fact_id(step_id, "input", item)
+ for item in command["parameters"]:
+ item["fact_id"] = _fact_id(step_id, "parameter", item)
+ for path in command["outputs"]:
+ if path in producers:
+ producers[path].append(step_id)
+
+ file_facts = [
+ {
+ "fact_id": f"output:file:{index:03d}",
+ "path": path,
+ "producer_step_ids": producers[path],
+ }
+ for index, path in enumerate(output_files, start=1)
+ ]
+ output_ids = {item["path"]: item["fact_id"] for item in file_facts}
+ stdout = deepcopy(evidence["outputs"]["stdout"])
+ if stdout.get("present"):
+ stdout["fact_id"] = "output:stdout"
+
+ facts: dict[str, Any] = {
+ "original_command": evidence["command"],
+ "package": {
+ "code_files": code_files,
+ "data_files": data_files,
+ "inventory": inventory,
+ },
+ "source_scripts": list(command_model["source_scripts"]),
+ "commands": commands,
+ "outputs": {
+ "files": file_facts,
+ "stdout": stdout,
+ },
+ "unmatched_output_fact_ids": [
+ output_ids[path] for path in command_model["unmatched_outputs"]
+ ],
+ "baseline": {
+ "success": evidence["success"],
+ "returncode": evidence["returncode"],
+ "logs": deepcopy(evidence["logs"]),
+ },
+ }
+ facts["facts_sha256"] = _facts_sha256(facts)
+ return facts
+
+
+def build_spec(evidence: dict[str, Any]) -> dict[str, Any]:
+ output_files = _output_files(evidence)
+ command_model = build_command_model(
+ command=evidence["command"],
+ work_dir=Path(evidence["work_dir"]),
+ before_files=evidence["before_files"],
+ output_files=output_files,
+ code_files=evidence["staged"]["code_files"],
+ )
+ return {
+ "schema_version": "lightweight-extension/v1",
+ "original_command": evidence["command"],
+ "source_scripts": command_model["source_scripts"],
+ "commands": command_model["commands"],
+ "unmatched_outputs": command_model["unmatched_outputs"],
+ "outputs": {
+ "files": output_files,
+ "stdout": dict(evidence["outputs"]["stdout"]),
+ },
+ "baseline": {
+ "success": evidence["success"],
+ "returncode": evidence["returncode"],
+ "logs": dict(evidence["logs"]),
+ },
+ }
+
+
+def write_spec(spec: dict[str, Any], destination: Path) -> Path:
+ destination = Path(destination)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(
+ json.dumps(spec, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return destination
+
+
+def write_wrapper(spec: dict[str, Any], destination: Path) -> Path:
+ destination = Path(destination)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ quoted_command = shlex.quote(spec["original_command"])
+ destination.write_text(
+ "#!/usr/bin/env bash\nset -euo pipefail\nexec bash -c "
+ + quoted_command
+ + "\n",
+ encoding="utf-8",
+ )
+ destination.chmod(0o755)
+ return destination
+
+
+def _baseline_input_paths(spec: dict[str, Any]) -> set[str]:
+ return {
+ item["path"]
+ for command in spec["commands"]
+ for item in command["inputs"]
+ if item["source"] == "baseline_input"
+ }
+
+
+def replay(
+ *,
+ spec: dict[str, Any],
+ raw_code: Path,
+ raw_data: Path,
+ run_dir: Path,
+ input_overrides: dict[str, Path] | None = None,
+ env: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ overrides = input_overrides or {}
+ unknown = sorted(set(overrides) - _baseline_input_paths(spec))
+ if unknown:
+ raise ValueError(f"Unknown input override: {', '.join(unknown)}")
+
+ evidence = run_raw_baseline(
+ command=spec["original_command"],
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=run_dir,
+ env=env,
+ input_overrides=overrides,
+ )
+ actual_files = set(evidence["outputs"]["new_files"]) | set(
+ evidence["outputs"]["modified_files"]
+ )
+ expected_files = set(spec["outputs"]["files"])
+ missing_outputs = sorted(expected_files - actual_files)
+ expected_stdout = bool(spec["outputs"]["stdout"]["present"])
+ stdout_ok = not expected_stdout or evidence["outputs"]["stdout"]["present"]
+ evidence["missing_outputs"] = missing_outputs
+ evidence["contract_passed"] = bool(
+ evidence["success"] and not missing_outputs and stdout_ok
+ )
+ return evidence
diff --git a/gfa_core/extension_stage_lightweight/deepseek_semantics.py b/gfa_core/extension_stage_lightweight/deepseek_semantics.py
new file mode 100644
index 000000000..0eaf843e2
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/deepseek_semantics.py
@@ -0,0 +1,397 @@
+"""Generate semantic-only candidates with an OpenAI-compatible DeepSeek API."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+import hashlib
+import json
+import os
+from pathlib import Path
+from typing import Any, Callable, Mapping
+
+from .semantic_model import bind_fact_derived_semantics, validate_semantic_candidate
+
+
+DEFAULT_BASE_URL = "https://api.deepseek.com"
+DEFAULT_MODEL = "deepseek-chat"
+MAX_FILE_BYTES = 32 * 1024
+MAX_TOTAL_BYTES = 128 * 1024
+
+SYSTEM_PROMPT = """You describe already-runnable bioinformatics code as a natural-language tool.
+Return one strict JSON object and no markdown. You must not return or modify execution facts,
+commands, paths, flags, values, observed outputs, or wrapper logic. Reference only fact_id values
+present in the supplied execution facts. Provide exactly one entry for every supplied input, parameter, and output fact_id, with no omissions, duplicates, or extras. Fill only function_summary and the allowed semantic fields.
+For result_role, final means a user-facing analysis result; intermediate means a temporary or
+supporting artifact used to produce or render another result; log means diagnostic execution output.
+Use unknown only when the supplied code and execution evidence cannot distinguish these roles.
+"""
+
+REPAIR_SYSTEM_PROMPT = """Return only a semantic patch as one strict JSON object and no markdown.
+Change only the supplied allowed_targets. Never return or modify commands, paths, flags, values,
+observed outputs, code, wrapper logic, or unknown fact_id values.
+"""
+
+
+@dataclass(frozen=True)
+class DeepSeekConfig:
+ env_file: Path
+ api_key: str
+ api_key_alias: str
+ base_url: str
+ model: str
+
+
+class DeepSeekSemanticError(RuntimeError):
+ def __init__(
+ self,
+ message: str,
+ *,
+ code: str,
+ metadata: dict[str, Any] | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.code = code
+ self.metadata = deepcopy(metadata or {})
+
+
+def _read_env_file(path: Path) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ if line.startswith("export "):
+ line = line[len("export ") :].strip()
+ key, value = line.split("=", 1)
+ key = key.strip()
+ if key:
+ values[key] = value.strip().strip("\"'")
+ return values
+
+
+def load_deepseek_config(
+ environ: Mapping[str, str] | None = None,
+) -> DeepSeekConfig:
+ source = os.environ if environ is None else environ
+ configured_path = source.get("BBAV5_PROJECT_ENV_FILE", "").strip()
+ if not configured_path:
+ raise DeepSeekSemanticError(
+ "BBAV5_PROJECT_ENV_FILE is required for DeepSeek semantic conversion",
+ code="configuration_error",
+ )
+ env_file = Path(configured_path).expanduser().resolve()
+ if not env_file.is_file():
+ raise DeepSeekSemanticError(
+ f"Configured project environment file does not exist: {env_file}",
+ code="configuration_error",
+ )
+
+ values = _read_env_file(env_file)
+ api_key_alias = "NEW_API_KEY" if values.get("NEW_API_KEY", "").strip() else "DEEPSEEK_API_KEY"
+ api_key = values.get(api_key_alias, "").strip()
+ if not api_key:
+ raise DeepSeekSemanticError(
+ f"DeepSeek API key is missing from configured environment file: {env_file}",
+ code="configuration_error",
+ )
+ base_url = (
+ values.get("NEW_API_BASE_URL", "").strip()
+ or values.get("DEEPSEEK_BASE_URL", "").strip()
+ or DEFAULT_BASE_URL
+ )
+ model = (
+ values.get("NEW_API_MODEL", "").strip()
+ or values.get("DEEPSEEK_MODEL", "").strip()
+ or DEFAULT_MODEL
+ )
+ return DeepSeekConfig(
+ env_file=env_file,
+ api_key=api_key,
+ api_key_alias=api_key_alias,
+ base_url=base_url,
+ model=model,
+ )
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def build_code_preview(raw_code: Path) -> dict[str, Any]:
+ root = Path(raw_code).resolve()
+ files: list[dict[str, Any]] = []
+ truncated_files: list[str] = []
+ remaining = MAX_TOTAL_BYTES
+ for path in sorted(item for item in root.rglob("*") if item.is_file()):
+ relative = path.relative_to(root).as_posix()
+ size = path.stat().st_size
+ base = {"path": relative, "size": size, "sha256": _sha256(path)}
+ with path.open("rb") as handle:
+ sample = handle.read(4096)
+ try:
+ sample.decode("utf-8")
+ is_binary = b"\x00" in sample
+ except UnicodeDecodeError:
+ is_binary = True
+ if is_binary:
+ files.append({**base, "kind": "binary"})
+ continue
+
+ limit = min(MAX_FILE_BYTES, remaining)
+ with path.open("rb") as handle:
+ content_bytes = handle.read(limit)
+ truncated = size > limit
+ files.append(
+ {
+ **base,
+ "kind": "text",
+ "content": content_bytes.decode("utf-8", errors="replace"),
+ "truncated": truncated,
+ }
+ )
+ remaining -= len(content_bytes)
+ if truncated:
+ truncated_files.append(relative)
+ return {"files": files, "truncated_files": truncated_files}
+
+
+def _default_client(config: DeepSeekConfig) -> Any:
+ try:
+ from openai import OpenAI
+ except Exception as exc: # pragma: no cover - environment dependent
+ raise DeepSeekSemanticError(
+ f"OpenAI-compatible client is unavailable: {exc}",
+ code="configuration_error",
+ ) from exc
+ return OpenAI(
+ api_key=config.api_key,
+ base_url=config.base_url,
+ timeout=90,
+ max_retries=0,
+ )
+
+
+def _metadata(
+ config: DeepSeekConfig,
+ *,
+ attempt: int,
+ preview: dict[str, Any],
+) -> dict[str, Any]:
+ return {
+ "env_file": str(config.env_file),
+ "base_url": config.base_url,
+ "model": config.model,
+ "api_key_alias": config.api_key_alias,
+ "attempt": attempt,
+ "request_id": None,
+ "usage": {},
+ "raw_response": "",
+ "code_preview": {
+ "truncated_files": list(preview["truncated_files"]),
+ "binary_files": [
+ item["path"] for item in preview["files"] if item["kind"] == "binary"
+ ],
+ },
+ }
+
+
+def _response_usage(response: Any) -> dict[str, int]:
+ usage = getattr(response, "usage", None)
+ if usage is None:
+ return {}
+ return {
+ "prompt_tokens": int(getattr(usage, "prompt_tokens", 0) or 0),
+ "completion_tokens": int(getattr(usage, "completion_tokens", 0) or 0),
+ "total_tokens": int(getattr(usage, "total_tokens", 0) or 0),
+ }
+
+
+def generate_semantic_candidate(
+ *,
+ execution_facts: dict[str, Any],
+ raw_code: Path,
+ environ: Mapping[str, str] | None = None,
+ client_factory: Callable[[DeepSeekConfig], Any] | None = None,
+ attempt: int = 1,
+) -> tuple[dict[str, Any], dict[str, Any]]:
+ config = load_deepseek_config(environ)
+ preview = build_code_preview(raw_code)
+ metadata = _metadata(config, attempt=attempt, preview=preview)
+ payload = {
+ "execution_facts": execution_facts,
+ "raw_code_preview": preview,
+ "required_candidate_shape": {
+ "function_summary": "one sentence",
+ "inputs": [
+ {
+ "fact_id": "existing input fact_id",
+ "role": "semantic role",
+ "description": "plain-language description",
+ "replaceability": (
+ "generated when execution_facts source is generated_input; "
+ "otherwise replaceable or fixed_resource"
+ ),
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": "existing parameter fact_id",
+ "display_name": "human-readable name",
+ "description": "plain-language description",
+ }
+ ],
+ "outputs": [
+ {
+ "fact_id": "existing output fact_id",
+ "description": "plain-language description",
+ "result_role": "final, intermediate, log, or unknown",
+ }
+ ],
+ },
+ }
+ client = (client_factory or _default_client)(config)
+ try:
+ response = client.chat.completions.create(
+ model=config.model,
+ messages=[
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ },
+ ],
+ temperature=0,
+ max_tokens=32000,
+ response_format={"type": "json_object"},
+ extra_body={"thinking": {"type": "enabled"}},
+ )
+ except DeepSeekSemanticError:
+ raise
+ except Exception as exc:
+ message = str(exc).replace(config.api_key, "[REDACTED]")
+ raise DeepSeekSemanticError(
+ f"DeepSeek API request failed: {message}",
+ code="api_request_failed",
+ metadata=metadata,
+ ) from exc
+
+ content = (response.choices[0].message.content or "").strip()
+ metadata["request_id"] = getattr(response, "id", None)
+ metadata["usage"] = _response_usage(response)
+ metadata["finish_reason"] = getattr(response.choices[0], "finish_reason", None)
+ metadata["raw_response"] = content
+ try:
+ candidate = json.loads(content)
+ except json.JSONDecodeError as exc:
+ raise DeepSeekSemanticError(
+ f"DeepSeek response is not valid JSON: {exc.msg}",
+ code="invalid_json",
+ metadata=metadata,
+ ) from exc
+ if not isinstance(candidate, dict):
+ raise DeepSeekSemanticError(
+ "DeepSeek response must be one JSON object",
+ code="invalid_json",
+ metadata=metadata,
+ )
+ if "facts_sha256" in candidate:
+ metadata["discarded_fact_fields"] = ["facts_sha256"]
+ candidate.pop("facts_sha256")
+ candidate["facts_sha256"] = execution_facts["facts_sha256"]
+ candidate, binding_audit = bind_fact_derived_semantics(
+ execution_facts, candidate
+ )
+ if binding_audit:
+ metadata["fact_derived_semantic_bindings"] = binding_audit
+
+ validation_errors = validate_semantic_candidate(execution_facts, candidate)
+ if validation_errors:
+ metadata["validation_errors"] = validation_errors
+ metadata["system_bound_candidate"] = deepcopy(candidate)
+ raise DeepSeekSemanticError(
+ "DeepSeek semantic candidate failed the locked-fact gate",
+ code="semantic_candidate_invalid",
+ metadata=metadata,
+ )
+ return candidate, metadata
+
+
+def generate_semantic_patch(
+ *,
+ repair_context: dict[str, Any],
+ raw_code: Path,
+ environ: Mapping[str, str] | None = None,
+ client_factory: Callable[[DeepSeekConfig], Any] | None = None,
+ attempt: int = 1,
+) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Ask DeepSeek for one semantic-only patch; validation stays in the repair gate."""
+
+ config = load_deepseek_config(environ)
+ preview = build_code_preview(raw_code)
+ metadata = _metadata(config, attempt=attempt, preview=preview)
+ payload = {
+ **deepcopy(repair_context),
+ "raw_code_preview": preview,
+ "required_patch_shape": {
+ "changes": [
+ {
+ "section": "root, inputs, parameters, or outputs",
+ "fact_id": "existing fact_id, or null for root",
+ "fields": {"allowed semantic field": "new string value"},
+ }
+ ],
+ },
+ }
+ client = (client_factory or _default_client)(config)
+ try:
+ response = client.chat.completions.create(
+ model=config.model,
+ messages=[
+ {"role": "system", "content": REPAIR_SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ },
+ ],
+ temperature=0,
+ max_tokens=6000,
+ response_format={"type": "json_object"},
+ )
+ except DeepSeekSemanticError:
+ raise
+ except Exception as exc:
+ message = str(exc).replace(config.api_key, "[REDACTED]")
+ raise DeepSeekSemanticError(
+ f"DeepSeek API repair request failed: {message}",
+ code="api_request_failed",
+ metadata=metadata,
+ ) from exc
+
+ content = (response.choices[0].message.content or "").strip()
+ metadata["request_id"] = getattr(response, "id", None)
+ metadata["usage"] = _response_usage(response)
+ metadata["raw_response"] = content
+ try:
+ patch = json.loads(content)
+ except json.JSONDecodeError as exc:
+ raise DeepSeekSemanticError(
+ f"DeepSeek repair response is not valid JSON: {exc.msg}",
+ code="invalid_json",
+ metadata=metadata,
+ ) from exc
+ if not isinstance(patch, dict):
+ raise DeepSeekSemanticError(
+ "DeepSeek repair response must be one JSON object",
+ code="invalid_json",
+ metadata=metadata,
+ )
+ if "facts_sha256" in patch:
+ metadata["discarded_fact_fields"] = ["facts_sha256"]
+ patch.pop("facts_sha256")
+ patch["facts_sha256"] = repair_context["execution_facts"]["facts_sha256"]
+ return patch, metadata
diff --git a/gfa_core/extension_stage_lightweight/evidence.py b/gfa_core/extension_stage_lightweight/evidence.py
new file mode 100644
index 000000000..d5249b749
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/evidence.py
@@ -0,0 +1,176 @@
+"""Collect execution facts without interpreting scientific semantics."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+from pathlib import Path
+import shutil
+from typing import Any, Callable
+
+from .process_control import ProcessIdentity, start_managed_process, stop_process_group
+
+
+def _files_below(root: Path) -> dict[str, Path]:
+ if not root.exists():
+ return {}
+ return {
+ path.relative_to(root).as_posix(): path
+ for path in sorted(root.rglob("*"))
+ if path.is_file()
+ }
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def inventory_files(root: Path) -> dict[str, dict[str, Any]]:
+ """Return a JSON-ready snapshot of every file visible below ``root``."""
+
+ inventory: dict[str, dict[str, Any]] = {}
+ for relative, path in _files_below(root).items():
+ inventory[relative] = {
+ "kind": "symlink" if path.is_symlink() else "file",
+ "size": path.stat().st_size,
+ "sha256": _sha256(path),
+ }
+ return inventory
+
+
+def stage_package(*, raw_code: Path, raw_data: Path, work_dir: Path) -> dict[str, list[str]]:
+ """Merge code and data into one work directory without changing the sources."""
+
+ raw_code = Path(raw_code).resolve()
+ raw_data = Path(raw_data).resolve()
+ code_files = _files_below(raw_code)
+ data_files = _files_below(raw_data)
+ collisions = sorted(set(code_files) & set(data_files))
+ if collisions:
+ raise ValueError(f"Code/data path collision: {', '.join(collisions)}")
+
+ work_dir = Path(work_dir)
+ work_dir.mkdir(parents=True, exist_ok=True)
+ for relative, source in code_files.items():
+ destination = work_dir / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.exists() or destination.is_symlink():
+ raise ValueError(f"Work directory path already exists: {relative}")
+ shutil.copy2(source, destination)
+
+ for relative, source in data_files.items():
+ destination = work_dir / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.exists() or destination.is_symlink():
+ raise ValueError(f"Work directory path already exists: {relative}")
+ destination.symlink_to(source)
+
+ return {
+ "code_files": sorted(code_files),
+ "data_files": sorted(data_files),
+ }
+
+
+def _stdout_fact(stdout_path: Path) -> dict[str, Any]:
+ size = stdout_path.stat().st_size
+ if not size:
+ return {"present": False, "path": str(stdout_path), "size": 0, "preview": ""}
+ text = stdout_path.read_text(encoding="utf-8", errors="replace").strip()
+ return {
+ "present": True,
+ "path": str(stdout_path),
+ "size": size,
+ "preview": text[-4000:],
+ }
+
+
+def run_raw_baseline(
+ *,
+ command: str,
+ raw_code: Path,
+ raw_data: Path,
+ run_dir: Path,
+ env: dict[str, str] | None = None,
+ input_overrides: dict[str, Path] | None = None,
+ on_process_start: Callable[[ProcessIdentity], None] | None = None,
+) -> dict[str, Any]:
+ """Run the exact entry command once and capture before/after evidence.
+
+ Deliberately no timeout is supplied: long-running bioinformatics processes are
+ not failures merely because they exceed an arbitrary wall-clock duration.
+ """
+
+ run_dir = Path(run_dir)
+ work_dir = run_dir / "work"
+ logs_dir = run_dir / "logs"
+ logs_dir.mkdir(parents=True, exist_ok=True)
+ staged = stage_package(raw_code=raw_code, raw_data=raw_data, work_dir=work_dir)
+ rebound: dict[str, str] = {}
+ for relative, source in sorted((input_overrides or {}).items()):
+ source = Path(source).resolve()
+ if not source.is_file():
+ raise ValueError(f"Input override does not exist: {source}")
+ destination = work_dir / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.is_dir() and not destination.is_symlink():
+ raise ValueError(f"Input override destination is a directory: {relative}")
+ if destination.exists() or destination.is_symlink():
+ destination.unlink()
+ destination.symlink_to(source)
+ rebound[relative] = str(source)
+ staged["input_overrides"] = rebound
+ before = inventory_files(work_dir)
+
+ stdout_path = logs_dir / "stdout.log"
+ stderr_path = logs_dir / "stderr.log"
+ process_env = os.environ.copy()
+ if env:
+ process_env.update(env)
+ with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle:
+ process, process_identity = start_managed_process(
+ ["bash", "-c", command],
+ cwd=work_dir,
+ env=process_env,
+ stdout=stdout_handle,
+ stderr=stderr_handle,
+ )
+ try:
+ if on_process_start is not None:
+ on_process_start(dict(process_identity))
+ except BaseException:
+ stop_process_group(process_identity)
+ process.wait()
+ raise
+ returncode = process.wait()
+
+ after = inventory_files(work_dir)
+ new_files = sorted(set(after) - set(before))
+ modified_files = sorted(
+ relative
+ for relative in set(after) & set(before)
+ if after[relative]["sha256"] != before[relative]["sha256"]
+ )
+ return {
+ "command": command,
+ "returncode": returncode,
+ "success": returncode == 0,
+ "process": process_identity,
+ "run_dir": str(run_dir),
+ "work_dir": str(work_dir),
+ "staged": staged,
+ "before_files": before,
+ "after_files": after,
+ "outputs": {
+ "new_files": new_files,
+ "modified_files": modified_files,
+ "stdout": _stdout_fact(stdout_path),
+ },
+ "logs": {
+ "stdout": str(stdout_path),
+ "stderr": str(stderr_path),
+ },
+ }
diff --git a/gfa_core/extension_stage_lightweight/process_control.py b/gfa_core/extension_stage_lightweight/process_control.py
new file mode 100644
index 000000000..d03d97faf
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/process_control.py
@@ -0,0 +1,389 @@
+"""Launch and explicitly stop extension commands under a stable supervisor."""
+
+from __future__ import annotations
+
+import ctypes
+import os
+from pathlib import Path
+import secrets
+import signal
+import subprocess
+import sys
+import time
+from typing import Any, Sequence, TypeAlias
+
+
+ProcessIdentity: TypeAlias = dict[str, int | str]
+
+CANCELLATION_GRACE_SECONDS = 0.2
+_PROC_READ_ATTEMPTS = 3
+_PROC_READ_RETRY_SECONDS = 0.005
+_SUPERVISOR_MARKER = "--bbav5-managed-supervisor-v1"
+_PR_SET_CHILD_SUBREAPER = 36
+_ALLOWED_POPEN_OPTIONS = {"cwd", "env", "stdin", "stdout", "stderr"}
+_PROC_UNCERTAIN = object()
+
+
+def _read_proc_stat(pid: int) -> tuple[str, int, int, int] | object | None:
+ """Return ``(state, pgrp, session, start_ticks)`` with race retries."""
+
+ path = Path(f"/proc/{pid}/stat")
+ for attempt in range(_PROC_READ_ATTEMPTS):
+ try:
+ text = path.read_text(encoding="utf-8")
+ fields = text.rsplit(")", 1)[1].split()
+ return fields[0], int(fields[2]), int(fields[3]), int(fields[19])
+ except (FileNotFoundError, ProcessLookupError):
+ if attempt + 1 < _PROC_READ_ATTEMPTS:
+ time.sleep(_PROC_READ_RETRY_SECONDS)
+ except (OSError, IndexError, ValueError):
+ return _PROC_UNCERTAIN
+ return None
+
+
+def _pid_start_time(pid: int) -> int | None:
+ stat = _read_proc_stat(pid)
+ return stat[3] if isinstance(stat, tuple) else None
+
+
+def _read_proc_cmdline(pid: int) -> list[str] | object | None:
+ path = Path(f"/proc/{pid}/cmdline")
+ for attempt in range(_PROC_READ_ATTEMPTS):
+ try:
+ return [
+ item.decode("utf-8", errors="surrogateescape")
+ for item in path.read_bytes().split(b"\0")
+ if item
+ ]
+ except (FileNotFoundError, ProcessLookupError):
+ if attempt + 1 < _PROC_READ_ATTEMPTS:
+ time.sleep(_PROC_READ_RETRY_SECONDS)
+ except OSError:
+ return _PROC_UNCERTAIN
+ return None
+
+
+def _is_matching_supervisor(
+ *, pid: int, pgid: int, start_time: int, token: str
+) -> bool:
+ stat = _read_proc_stat(pid)
+ if (
+ not isinstance(stat, tuple)
+ or stat[0] == "Z"
+ or stat[1:] != (pgid, pgid, start_time)
+ ):
+ return False
+ command = _read_proc_cmdline(pid)
+ if not isinstance(command, list) or len(command) < 7:
+ return False
+ try:
+ return bool(
+ Path(command[1]).resolve() == Path(__file__).resolve()
+ and command[2] == _SUPERVISOR_MARKER
+ and command[5] == token
+ and command[6] == "--"
+ )
+ except OSError:
+ return False
+
+
+def _set_child_subreaper() -> None:
+ libc = ctypes.CDLL(None, use_errno=True)
+ if libc.prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
+ error = ctypes.get_errno()
+ raise OSError(error, os.strerror(error))
+
+
+def _mirror_returncode(returncode: int) -> None:
+ if returncode < 0:
+ signum = -returncode
+ if signum != signal.SIGKILL:
+ signal.signal(signum, signal.SIG_DFL)
+ os.kill(os.getpid(), signum)
+ os._exit(128 + signum)
+ os._exit(returncode)
+
+
+def _supervise(command: Sequence[str], ready_fd: int, release_fd: int) -> None:
+ """Run the exact argv, then wait for every descendant as a subreaper."""
+
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ try:
+ _set_child_subreaper()
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
+ try:
+ child = subprocess.Popen(command)
+ finally:
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ except BaseException:
+ os.write(ready_fd, b"E")
+ os.close(ready_fd)
+ os.close(release_fd)
+ raise
+
+ os.write(ready_fd, b"1")
+ os.close(ready_fd)
+ os.read(release_fd, 1)
+ os.close(release_fd)
+
+ command_returncode: int | None = None
+ while True:
+ try:
+ reaped_pid, status = os.waitpid(-1, 0)
+ except ChildProcessError:
+ break
+ if reaped_pid == child.pid:
+ command_returncode = os.waitstatus_to_exitcode(status)
+ child.returncode = command_returncode
+
+ if command_returncode is None:
+ command_returncode = child.poll()
+ _mirror_returncode(command_returncode if command_returncode is not None else 1)
+
+
+def _supervisor_entry(argv: Sequence[str]) -> None:
+ if len(argv) < 6 or argv[0] != _SUPERVISOR_MARKER or argv[4] != "--":
+ raise SystemExit(2)
+ ready_fd = int(argv[1])
+ release_fd = int(argv[2])
+ _token = argv[3]
+ command = argv[5:]
+ if not command:
+ raise SystemExit(2)
+ _supervise(command, ready_fd, release_fd)
+
+
+def start_managed_process(
+ args: Sequence[str], **kwargs: Any
+) -> tuple[subprocess.Popen[bytes], ProcessIdentity]:
+ """Start ``args`` below a stable session/group supervisor anchor."""
+
+ if not args:
+ raise ValueError("Managed process command must not be empty")
+ unsupported = sorted(set(kwargs) - _ALLOWED_POPEN_OPTIONS)
+ if unsupported:
+ raise TypeError(f"Unsupported managed process option: {', '.join(unsupported)}")
+
+ ready_read, ready_write = os.pipe()
+ release_read, release_write = os.pipe()
+ token = secrets.token_hex(16)
+ supervisor_args = [
+ sys.executable,
+ str(Path(__file__).resolve()),
+ _SUPERVISOR_MARKER,
+ str(ready_write),
+ str(release_read),
+ token,
+ "--",
+ *(str(item) for item in args),
+ ]
+ try:
+ process = subprocess.Popen(
+ supervisor_args,
+ start_new_session=True,
+ pass_fds=(ready_write, release_read),
+ **kwargs,
+ )
+ except BaseException:
+ for file_descriptor in (ready_read, ready_write, release_read, release_write):
+ os.close(file_descriptor)
+ raise
+ os.close(ready_write)
+ os.close(release_read)
+
+ try:
+ ready = os.read(ready_read, 1)
+ if ready != b"1":
+ os.close(release_write)
+ process.wait()
+ raise RuntimeError("Managed supervisor could not launch the command")
+ pid = process.pid
+ pgid = os.getpgid(pid)
+ start_time = _pid_start_time(pid)
+ identity: ProcessIdentity = {
+ "pid": pid,
+ "pgid": pgid,
+ "pid_start_time": start_time or 0,
+ "supervisor_token": token,
+ }
+ if not _is_matching_supervisor(
+ pid=pid,
+ pgid=pgid,
+ start_time=start_time or 0,
+ token=token,
+ ):
+ os.kill(pid, signal.SIGKILL)
+ os.close(release_write)
+ process.wait()
+ raise RuntimeError("Managed supervisor identity could not be verified")
+ os.write(release_write, b"1")
+ return process, identity
+ finally:
+ os.close(ready_read)
+ try:
+ os.close(release_write)
+ except OSError:
+ pass
+
+
+def _group_membership(pgid: int, supervisor_pid: int) -> str:
+ uncertain = False
+ try:
+ for proc_path in Path("/proc").iterdir():
+ if not proc_path.name.isdigit() or int(proc_path.name) == supervisor_pid:
+ continue
+ stat = _read_proc_stat(int(proc_path.name))
+ if stat is _PROC_UNCERTAIN:
+ uncertain = True
+ continue
+ if stat is None:
+ try:
+ uncertain = uncertain or proc_path.exists()
+ except OSError:
+ uncertain = True
+ continue
+ if stat[0] != "Z" and stat[1] == pgid and stat[2] == pgid:
+ return "member"
+ except OSError:
+ return "uncertain"
+ return "uncertain" if uncertain else "empty"
+
+
+def _supervisor_state(
+ *,
+ pid: int,
+ pgid: int,
+ start_time: int,
+ token: str,
+ verify_absent_exit: bool = False,
+) -> str:
+ stat = _read_proc_stat(pid)
+ if stat is _PROC_UNCERTAIN:
+ return "lost"
+ if stat is None:
+ if verify_absent_exit:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return (
+ "matching"
+ if _group_membership(pgid, pid) != "empty"
+ else "exited"
+ )
+ except OSError:
+ pass
+ return "lost"
+ if stat[1:] != (pgid, pgid, start_time):
+ return "lost"
+ if stat[0] == "Z":
+ if verify_absent_exit:
+ return "matching"
+ return "lost" if _group_membership(pgid, pid) != "empty" else "exited"
+ if _is_matching_supervisor(
+ pid=pid, pgid=pgid, start_time=start_time, token=token
+ ):
+ return "matching"
+ stat = _read_proc_stat(pid)
+ if (
+ isinstance(stat, tuple)
+ and stat[1:] == (pgid, pgid, start_time)
+ and stat[0] == "Z"
+ ):
+ if verify_absent_exit:
+ return "matching"
+ return "lost" if _group_membership(pgid, pid) != "empty" else "exited"
+ return "lost"
+
+
+def _wait_for_supervisor_state(
+ *,
+ pid: int,
+ pgid: int,
+ start_time: int,
+ token: str,
+ grace_seconds: float,
+ verify_absent_exit: bool = False,
+) -> str:
+ deadline = time.monotonic() + grace_seconds
+ while True:
+ state = _supervisor_state(
+ pid=pid,
+ pgid=pgid,
+ start_time=start_time,
+ token=token,
+ verify_absent_exit=verify_absent_exit,
+ )
+ if (
+ verify_absent_exit
+ and state == "lost"
+ and time.monotonic() < deadline
+ ):
+ time.sleep(0.01)
+ continue
+ if state != "matching":
+ return state
+ if time.monotonic() >= deadline:
+ return "running"
+ time.sleep(0.01)
+
+
+def stop_process_group(
+ identity: ProcessIdentity, *, grace_seconds: float = CANCELLATION_GRACE_SECONDS
+) -> bool:
+ """Stop only the group owned by the captured, persistent supervisor."""
+
+ try:
+ pid = int(identity["pid"])
+ pgid = int(identity["pgid"])
+ start_time = int(identity["pid_start_time"])
+ token = str(identity["supervisor_token"])
+ except (KeyError, TypeError, ValueError):
+ return False
+ if (
+ pid <= 0
+ or pgid <= 0
+ or pid != pgid
+ or start_time <= 0
+ or not token
+ or grace_seconds < 0
+ ):
+ return False
+ if not _is_matching_supervisor(
+ pid=pid, pgid=pgid, start_time=start_time, token=token
+ ):
+ return False
+ try:
+ os.killpg(pgid, signal.SIGTERM)
+ except ProcessLookupError:
+ return False
+ state = _wait_for_supervisor_state(
+ pid=pid,
+ pgid=pgid,
+ start_time=start_time,
+ token=token,
+ grace_seconds=grace_seconds,
+ )
+ if state == "exited":
+ return True
+ if state == "lost":
+ return False
+ if not _is_matching_supervisor(
+ pid=pid, pgid=pgid, start_time=start_time, token=token
+ ):
+ return False
+ try:
+ os.killpg(pgid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ return _wait_for_supervisor_state(
+ pid=pid,
+ pgid=pgid,
+ start_time=start_time,
+ token=token,
+ grace_seconds=grace_seconds,
+ verify_absent_exit=True,
+ ) == "exited"
+
+
+if __name__ == "__main__":
+ _supervisor_entry(sys.argv[1:])
diff --git a/gfa_core/extension_stage_lightweight/runtime_binding.py b/gfa_core/extension_stage_lightweight/runtime_binding.py
new file mode 100644
index 000000000..03abec7d7
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/runtime_binding.py
@@ -0,0 +1,2105 @@
+"""Build an evidence-owned contract for later runtime binding."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import datetime, timezone
+import hashlib
+import json
+import os
+from pathlib import Path
+from pathlib import PurePosixPath
+import re
+import shlex
+import shutil
+import tempfile
+from typing import Any, Callable
+
+from .command_model import _prepare_script_commands
+from .evidence import inventory_files
+from .process_control import ProcessIdentity, start_managed_process, stop_process_group
+
+
+SCHEMA_VERSION = "lightweight-runtime-binding/v1"
+
+
+class RuntimeBindingError(RuntimeError):
+ """A deterministic runtime binding could not be rendered safely."""
+
+ def __init__(self, code: str, message: str) -> None:
+ self.code = code
+ super().__init__(f"{code}: {message}")
+
+
+def _sha256_json(value: Any) -> str:
+ payload = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _digest_without(value: dict[str, Any], field: str) -> str:
+ return _sha256_json({key: item for key, item in value.items() if key != field})
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _verify_raw_code(
+ inventory: dict[str, dict[str, Any]], raw_code: Path
+) -> None:
+ for relative, record in sorted(inventory.items()):
+ if record.get("source") != "raw_code":
+ continue
+ source = raw_code / relative
+ if not source.is_file():
+ raise ValueError(f"Code file is missing after baseline: {relative}")
+ if _sha256_file(source) != record.get("sha256"):
+ raise ValueError(f"Code file changed after baseline: {relative}")
+
+
+def _semantic_text_by_fact(
+ candidate: dict[str, Any], section: str, allowed_fields: set[str]
+) -> dict[str, dict[str, str]]:
+ projected: dict[str, dict[str, str]] = {}
+ entries = candidate.get(section, []) if isinstance(candidate, dict) else []
+ if not isinstance(entries, list):
+ return projected
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ fact_id = entry.get("fact_id")
+ if not isinstance(fact_id, str) or fact_id in projected:
+ continue
+ text = {
+ field: value.strip()
+ for field in allowed_fields
+ if isinstance((value := entry.get(field)), str) and value.strip()
+ }
+ projected[fact_id] = text
+ return projected
+
+
+def _source_scripts(
+ execution_facts: dict[str, Any], raw_code: Path
+) -> tuple[list[dict[str, Any]], dict[str, list[str]]]:
+ inventory = execution_facts["package"]["inventory"]
+ records: list[dict[str, Any]] = []
+ words_by_script: dict[str, list[str]] = {}
+ for relative in execution_facts.get("source_scripts", []):
+ package_record = inventory.get(relative)
+ if (
+ not isinstance(package_record, dict)
+ or package_record.get("source") != "raw_code"
+ ):
+ raise ValueError(f"Source script is not locked raw code: {relative}")
+ source = raw_code / relative
+ if not source.is_file():
+ raise ValueError(f"Source script does not exist in raw_code: {relative}")
+ actual_hash = _sha256_file(source)
+ records.append(
+ {"path": relative, "sha256": actual_hash, "locked": True}
+ )
+ lexer = shlex.shlex(
+ source.read_text(encoding="utf-8", errors="replace"),
+ posix=True,
+ punctuation_chars="|&;()<>",
+ )
+ lexer.whitespace_split = True
+ lexer.commenters = "#"
+ try:
+ words_by_script[relative] = list(lexer)
+ except ValueError:
+ words_by_script[relative] = []
+ return records, words_by_script
+
+
+def _package_files(
+ inventory: dict[str, dict[str, Any]], exposed_input_paths: set[str]
+) -> list[dict[str, Any]]:
+ return [
+ {
+ "path": path,
+ "source": record.get("source"),
+ "kind": record.get("kind"),
+ "size": record.get("size"),
+ "sha256": record.get("sha256"),
+ "locked": record.get("source") == "raw_code",
+ "exposed_as_input": path in exposed_input_paths,
+ }
+ for path, record in sorted(inventory.items())
+ ]
+
+
+def _command_anchor(command: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "step_id": command["step_id"],
+ "command_digest": hashlib.sha256(
+ command["command"].encode("utf-8")
+ ).hexdigest(),
+ }
+
+
+def _command_tokens(command: dict[str, Any]) -> list[str]:
+ try:
+ return shlex.split(command["command"])
+ except ValueError:
+ return []
+
+
+def _fact_anchor(
+ fact: dict[str, Any], command: dict[str, Any], token_index: int
+) -> dict[str, Any]:
+ return {
+ "fact_id": fact["fact_id"],
+ **_command_anchor(command),
+ "token_index": token_index,
+ }
+
+
+def _input_anchor(
+ fact: dict[str, Any], command: dict[str, Any]
+) -> tuple[dict[str, Any] | None, str | None]:
+ token_index = fact["token_index"]
+ tokens = _command_tokens(command)
+ if token_index >= len(tokens) or tokens[token_index] != fact["path"]:
+ return None, "input_reference_anchor_not_found"
+ return _fact_anchor(fact, command, token_index), None
+
+
+def _parameter_anchor(
+ fact: dict[str, Any], command: dict[str, Any]
+) -> tuple[dict[str, Any] | None, str | None]:
+ token_index = fact["token_index"]
+ tokens = _command_tokens(command)
+ if token_index >= len(tokens):
+ return None, "parameter_reference_anchor_not_found"
+ if fact["kind"] == "flag_value":
+ if "=" in tokens[token_index]:
+ return None, "inline_flag_value_not_supported"
+ token_index += 1
+ literal = fact["flag"] if fact["kind"] == "switch" else fact["value"]
+ if token_index >= len(tokens) or tokens[token_index] != literal:
+ return None, "parameter_reference_anchor_not_found"
+ return _fact_anchor(fact, command, token_index), None
+
+
+def _output_anchors(
+ output: dict[str, Any], commands_by_step: dict[str, dict[str, Any]]
+) -> tuple[list[dict[str, Any]], str | None]:
+ anchors: list[dict[str, Any]] = []
+ producer_step_ids = output.get("producer_step_ids", [])
+ if not producer_step_ids:
+ return [], "output_reference_anchor_not_found"
+ for step_id in producer_step_ids:
+ command = commands_by_step.get(step_id)
+ if command is None:
+ return [], "output_reference_anchor_not_found"
+ matching_indexes = [
+ index
+ for index, token in enumerate(_command_tokens(command))
+ if token == output["path"]
+ ]
+ if not matching_indexes:
+ return [], "output_reference_anchor_not_found"
+ if len(matching_indexes) != 1:
+ return [], "output_reference_anchor_ambiguous"
+ anchors.append(_fact_anchor(output, command, matching_indexes[0]))
+ return anchors, None
+
+
+def _patch_candidates(
+ commands: list[dict[str, Any]],
+ inputs: list[dict[str, Any]],
+ parameters: list[dict[str, Any]],
+ path_references: list[dict[str, Any]],
+ output_files: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ commands_by_step = {command["step_id"]: command for command in commands}
+ path_members: dict[str, list[dict[str, Any]]] = {}
+ for item in inputs:
+ command = commands_by_step[item["step_id"]]
+ anchor, reason = _input_anchor(item, command)
+ path_members.setdefault(item["path"], []).append(
+ {
+ "fact_id": item["fact_id"],
+ "member_kind": (
+ "input" if item["replaceable"] else "generated_input"
+ ),
+ "anchors": [anchor] if anchor else [],
+ "reason": reason,
+ }
+ )
+ for item in output_files:
+ anchors, reason = _output_anchors(item, commands_by_step)
+ path_members.setdefault(item["path"], []).append(
+ {
+ "fact_id": item["fact_id"],
+ "member_kind": "output",
+ "anchors": anchors,
+ "producer_step_ids": deepcopy(
+ item.get("producer_step_ids", [])
+ ),
+ "reason": reason,
+ }
+ )
+
+ candidates: list[dict[str, Any]] = []
+ for path, members in path_members.items():
+ member_kinds = {member["member_kind"] for member in members}
+ reasons = [member["reason"] for member in members if member["reason"]]
+ if "generated_input" in member_kinds and "output" not in member_kinds:
+ reasons.append("generated_input_without_output_binding")
+ producer_step_ids = [
+ step_id
+ for member in members
+ for step_id in member.get("producer_step_ids", [])
+ ]
+ candidates.append(
+ {
+ "binding_key": f"path:{path}",
+ "fact_ids": [member["fact_id"] for member in members],
+ "anchors": [
+ anchor
+ for member in members
+ for anchor in member["anchors"]
+ ],
+ "original_literal": path,
+ "patch_kind": (
+ "output_path" if "output" in member_kinds else "input_path"
+ ),
+ **(
+ {"producer_step_ids": producer_step_ids}
+ if producer_step_ids
+ else {}
+ ),
+ **({"_unpatchable_reason": reasons[0]} if reasons else {}),
+ }
+ )
+
+ for item in parameters:
+ command = commands_by_step[item["step_id"]]
+ anchor, reason = _parameter_anchor(item, command)
+ candidate = {
+ "binding_key": f"parameter:{item['fact_id']}",
+ "fact_ids": [item["fact_id"]],
+ "anchors": [anchor] if anchor else [],
+ "original_literal": (
+ item["flag"] if item["kind"] == "switch" else item["value"]
+ ),
+ "patch_kind": (
+ "parameter_switch"
+ if item["kind"] == "switch"
+ else "parameter_value"
+ ),
+ }
+ if reason:
+ candidate["_unpatchable_reason"] = reason
+ candidates.append(candidate)
+ for item in path_references:
+ command = commands_by_step[item["step_id"]]
+ anchor, reason = _parameter_anchor(item, command)
+ candidate = {
+ "binding_key": f"path_reference:{item['fact_id']}",
+ "fact_ids": [item["fact_id"]],
+ "anchors": [anchor] if anchor else [],
+ "original_literal": item["path"],
+ "patch_kind": "output_reference_path",
+ }
+ if reason:
+ candidate["_unpatchable_reason"] = reason
+ candidates.append(candidate)
+ return candidates
+
+
+def _locate_patch_targets(
+ candidates: list[dict[str, Any]],
+ source_scripts: list[dict[str, Any]],
+ words_by_script: dict[str, list[str]],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ targets: list[dict[str, Any]] = []
+ unpatchable: list[dict[str, Any]] = []
+ for candidate in candidates:
+ reason = candidate.get("_unpatchable_reason")
+ source_matches = [
+ (
+ script,
+ words_by_script[script["path"]].count(
+ candidate["original_literal"]
+ ),
+ )
+ for script in source_scripts
+ if candidate["original_literal"] in words_by_script[script["path"]]
+ ]
+ anchors = candidate["anchors"]
+ anchor_identities = {
+ (
+ anchor["step_id"],
+ anchor["token_index"],
+ anchor["command_digest"],
+ )
+ for anchor in anchors
+ }
+ if reason is None:
+ if len(source_matches) > 1 or len(anchor_identities) != len(anchors):
+ reason = "source_literal_ambiguous"
+ elif (
+ len(source_matches) == 1
+ and source_matches[0][1] != len(anchors)
+ ):
+ reason = "source_literal_ambiguous"
+ elif not source_matches:
+ reason = "source_literal_not_found_in_raw_shell"
+
+ clean_candidate = {
+ key: value
+ for key, value in candidate.items()
+ if key != "_unpatchable_reason"
+ }
+ if reason is not None:
+ unpatchable.append(
+ {
+ **clean_candidate,
+ "reason": reason,
+ }
+ )
+ continue
+ script, count = source_matches[0]
+ target = {
+ **clean_candidate,
+ "script_path": script["path"],
+ "script_hash": script["sha256"],
+ "expected_exact_match_count": count,
+ }
+ if len(anchors) == 1:
+ target.update(
+ {
+ "step_id": anchors[0]["step_id"],
+ "token_index": anchors[0]["token_index"],
+ "command_digest": anchors[0]["command_digest"],
+ }
+ )
+ targets.append(target)
+ return targets, unpatchable
+
+
+def build_runtime_binding_contract(
+ execution_facts: dict[str, Any],
+ semantic_candidate: dict[str, Any],
+ raw_code: Path,
+) -> dict[str, Any]:
+ """Project locked execution facts into a runtime-binding contract.
+
+ Semantic data is optional display text. It never controls commands, paths,
+ values, ownership, or replaceability.
+ """
+
+ raw_code = Path(raw_code)
+ inventory = execution_facts["package"]["inventory"]
+ _verify_raw_code(inventory, raw_code)
+ input_text = _semantic_text_by_fact(
+ semantic_candidate, "inputs", {"role", "description"}
+ )
+ parameter_text = _semantic_text_by_fact(
+ semantic_candidate, "parameters", {"display_name", "description"}
+ )
+ output_text = _semantic_text_by_fact(
+ semantic_candidate, "outputs", {"description", "result_role"}
+ )
+
+ inputs: list[dict[str, Any]] = []
+ parameters: list[dict[str, Any]] = []
+ path_references: list[dict[str, Any]] = []
+ for command in execution_facts.get("commands", []):
+ step_id = command["step_id"]
+ for fact in command.get("inputs", []):
+ replaceable = fact.get("source") == "baseline_input"
+ inputs.append(
+ {
+ **deepcopy(fact),
+ "step_id": step_id,
+ "replaceable": replaceable,
+ "locked": not replaceable,
+ **input_text.get(fact["fact_id"], {}),
+ }
+ )
+ for fact in command.get("parameters", []):
+ if fact.get("value_role") == "output_reference":
+ path_references.append(
+ {
+ **deepcopy(fact),
+ "step_id": step_id,
+ "path": fact["value"],
+ }
+ )
+ continue
+ if fact.get("value_role") == "input":
+ continue
+ parameters.append(
+ {
+ **deepcopy(fact),
+ "step_id": step_id,
+ **parameter_text.get(fact["fact_id"], {}),
+ }
+ )
+
+ output_files = [
+ {
+ **deepcopy(fact),
+ **output_text.get(fact["fact_id"], {}),
+ }
+ for fact in execution_facts.get("outputs", {}).get("files", [])
+ ]
+ stdout = deepcopy(execution_facts.get("outputs", {}).get("stdout", {}))
+ stdout_fact_id = stdout.get("fact_id")
+ if isinstance(stdout_fact_id, str):
+ stdout.update(output_text.get(stdout_fact_id, {}))
+
+ scripts, words_by_script = _source_scripts(execution_facts, raw_code)
+ patch_targets, unpatchable_targets = _locate_patch_targets(
+ _patch_candidates(
+ execution_facts.get("commands", []),
+ inputs,
+ parameters,
+ path_references,
+ output_files,
+ ),
+ scripts,
+ words_by_script,
+ )
+ exposed_input_paths = {
+ item["path"] for item in inputs if item["replaceable"]
+ }
+ contract = {
+ "schema_version": SCHEMA_VERSION,
+ "facts_sha256": execution_facts["facts_sha256"],
+ "package_digest": _sha256_json(inventory),
+ "original_command": execution_facts["original_command"],
+ "source_scripts": scripts,
+ "package_files": _package_files(inventory, exposed_input_paths),
+ "inputs": inputs,
+ "parameters": parameters,
+ "path_references": path_references,
+ "outputs": {"files": output_files, "stdout": stdout},
+ "patch_targets": patch_targets,
+ "unpatchable_targets": unpatchable_targets,
+ }
+ contract["contract_digest"] = _digest_without(contract, "contract_digest")
+ return contract
+
+
+_SWITCH_TOKEN = re.compile(r"-{1,2}[A-Za-z0-9][A-Za-z0-9_.:+-]*\Z")
+_PREFIX_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
+_PREFIX_DELIMITERS = ".-_"
+
+
+def _resolved_result(
+ contract: dict[str, Any],
+ *,
+ status: str,
+ input_bindings: list[dict[str, Any]] | None = None,
+ parameter_bindings: list[dict[str, Any]] | None = None,
+ output_bindings: list[dict[str, Any]] | None = None,
+ prefix_binding: dict[str, Any] | None = None,
+ error: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ result = {
+ "status": status,
+ "facts_sha256": contract.get("facts_sha256"),
+ "package_digest": contract.get("package_digest"),
+ "contract_digest": contract.get("contract_digest"),
+ "input_bindings": input_bindings or [],
+ "parameter_bindings": parameter_bindings or [],
+ "output_bindings": output_bindings or [],
+ "prefix_binding": prefix_binding,
+ "error": error,
+ }
+ result["binding_digest"] = _digest_without(result, "binding_digest")
+ return result
+
+
+def _blocked_result(
+ contract: dict[str, Any],
+ code: str,
+ message: str,
+ *,
+ input_bindings: list[dict[str, Any]] | None = None,
+ parameter_bindings: list[dict[str, Any]] | None = None,
+ **details: Any,
+) -> dict[str, Any]:
+ return _resolved_result(
+ contract,
+ status="blocked",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ error={"code": code, "message": message, **details},
+ )
+
+
+def _has_control(value: str) -> bool:
+ return any(ord(character) < 32 or ord(character) == 127 for character in value)
+
+
+def _input_groups(
+ contract: dict[str, Any],
+) -> tuple[dict[str, list[dict[str, Any]]], dict[str, str], set[str]]:
+ groups: dict[str, list[dict[str, Any]]] = {}
+ for item in contract.get("inputs", []):
+ if not isinstance(item, dict) or not item.get("replaceable"):
+ continue
+ path = item.get("path")
+ if isinstance(path, str) and path:
+ groups.setdefault(path, []).append(item)
+
+ aliases: dict[str, str] = {}
+ ambiguous: set[str] = set()
+ for path, items in groups.items():
+ for alias in [path, *(item.get("fact_id") for item in items)]:
+ if not isinstance(alias, str) or not alias:
+ continue
+ if alias in aliases and aliases[alias] != path:
+ ambiguous.add(alias)
+ else:
+ aliases[alias] = path
+ roles: dict[str, set[str]] = {}
+ for path, items in groups.items():
+ for item in items:
+ role = item.get("role")
+ if isinstance(role, str) and role:
+ roles.setdefault(role, set()).add(path)
+ for role, paths in roles.items():
+ if len(paths) == 1:
+ aliases[role] = next(iter(paths))
+ else:
+ ambiguous.add(role)
+ for alias in ambiguous:
+ aliases.pop(alias, None)
+ return groups, aliases, ambiguous
+
+
+def _resolve_inputs(
+ contract: dict[str, Any], input_overrides: dict[str, Any]
+) -> tuple[list[dict[str, Any]] | None, dict[str, Any] | None]:
+ groups, aliases, ambiguous = _input_groups(contract)
+ selected: dict[str, list[Path]] = {path: [] for path in groups}
+ for alias, value in input_overrides.items():
+ if alias in ambiguous:
+ return None, {
+ "code": "INPUT_BINDING_AMBIGUOUS",
+ "message": f"Input alias is not unique: {alias}",
+ }
+ original_path = aliases.get(alias)
+ if original_path is None:
+ return None, {
+ "code": "INPUT_NOT_DECLARED",
+ "message": f"Input is not declared by the contract: {alias}",
+ "available_fact_ids": sorted(
+ item["fact_id"]
+ for items in groups.values()
+ for item in items
+ if isinstance(item.get("fact_id"), str)
+ ),
+ }
+ try:
+ expanded = Path(value).expanduser()
+ selected[original_path].append(Path(os.path.abspath(expanded)))
+ except (TypeError, ValueError, OSError):
+ return None, {
+ "code": "INPUT_PATH_NOT_FOUND",
+ "message": f"Input path is invalid: {value}",
+ }
+
+ bindings: list[dict[str, Any]] = []
+ for original_path, items in sorted(groups.items()):
+ paths = selected[original_path]
+ if not paths:
+ return None, {
+ "code": "INPUT_BINDING_MISSING",
+ "message": f"Required input has no runtime binding: {original_path}",
+ }
+ distinct = {str(path) for path in paths}
+ if len(distinct) != 1:
+ return None, {
+ "code": "INPUT_BINDING_CONFLICT",
+ "message": f"Input has conflicting runtime bindings: {original_path}",
+ }
+ runtime_path = paths[0]
+ if not runtime_path.is_file():
+ return None, {
+ "code": "INPUT_PATH_NOT_FOUND",
+ "message": f"Runtime input file does not exist: {runtime_path}",
+ }
+ bindings.append(
+ {
+ "original_path": original_path,
+ "runtime_path": str(runtime_path),
+ "fact_ids": sorted(
+ item["fact_id"]
+ for item in items
+ if isinstance(item.get("fact_id"), str)
+ ),
+ }
+ )
+ return bindings, None
+
+
+def _resolve_parameters(
+ contract: dict[str, Any], parameter_overrides: dict[str, Any]
+) -> tuple[list[dict[str, Any]] | None, dict[str, Any] | None]:
+ declared = {
+ item["fact_id"]: item
+ for item in contract.get("parameters", [])
+ if isinstance(item, dict) and isinstance(item.get("fact_id"), str)
+ }
+ unknown = sorted(set(parameter_overrides) - set(declared))
+ if unknown:
+ return None, {
+ "code": "PARAMETER_NOT_DECLARED",
+ "message": f"Parameter is not declared by the original command: {unknown[0]}",
+ "available_fact_ids": sorted(declared),
+ }
+
+ bindings: list[dict[str, Any]] = []
+ for fact_id, item in sorted(declared.items()):
+ original = item.get("value", item.get("flag", ""))
+ runtime = parameter_overrides.get(fact_id, original)
+ runtime_value = str(runtime)
+ if not runtime_value or _has_control(runtime_value):
+ return None, {
+ "code": "PARAMETER_VALUE_INVALID",
+ "message": f"Parameter contains an unsafe value: {fact_id}",
+ }
+ if item.get("kind") == "switch" and not _SWITCH_TOKEN.fullmatch(
+ runtime_value
+ ):
+ return None, {
+ "code": "PARAMETER_VALUE_INVALID",
+ "message": f"Switch must be one safe option token: {fact_id}",
+ }
+ bindings.append(
+ {
+ "fact_id": fact_id,
+ "kind": item.get("kind"),
+ "flag": item.get("flag"),
+ "original_value": str(original),
+ "runtime_value": runtime_value,
+ "changed": runtime_value != str(original),
+ }
+ )
+ return bindings, None
+
+
+def _validate_prefix(value: Any) -> bool:
+ return (
+ isinstance(value, str)
+ and ".." not in value
+ and _PREFIX_TOKEN.fullmatch(value) is not None
+ )
+
+
+def _output_has_prefix(output_path: str, prefix: str) -> bool:
+ name = PurePosixPath(output_path).name
+ return name.startswith(prefix) and (
+ len(name) == len(prefix)
+ or name[len(prefix)] in _PREFIX_DELIMITERS
+ )
+
+
+def _replace_output_prefix(output_path: str, old: str, new: str) -> str:
+ path = PurePosixPath(output_path)
+ name = new + path.name[len(old) :]
+ return name if str(path.parent) == "." else str(path.parent / name)
+
+
+def _shared_output_prefix(outputs: list[dict[str, Any]]) -> str | None:
+ """Return one delimiter-owned prefix from declared output filenames."""
+
+ names = [PurePosixPath(item["path"]).name for item in outputs]
+ if not names:
+ return None
+ if len(names) == 1:
+ suffix = PurePosixPath(names[0]).suffix
+ candidate = names[0][: -len(suffix)] if suffix else names[0]
+ else:
+ candidate = os.path.commonprefix(names).rstrip("._-")
+ if not _validate_prefix(candidate):
+ return None
+ if not all(_output_has_prefix(item["path"], candidate) for item in outputs):
+ return None
+ return candidate
+
+
+def _runtime_input_owned_output(
+ output_path: str,
+ runtime_by_original: dict[str, str],
+ *,
+ allow_sidecar: bool,
+) -> str | None:
+ """Preserve outputs that are the runtime input itself or its sidecar."""
+
+ candidates = set()
+ for original_input, runtime_input in runtime_by_original.items():
+ if output_path == original_input:
+ suffix = ""
+ elif allow_sidecar and output_path.startswith(original_input) and output_path[
+ len(original_input) : len(original_input) + 1
+ ] in _PREFIX_DELIMITERS:
+ suffix = output_path[len(original_input) :]
+ else:
+ continue
+ candidates.add(Path(runtime_input).name + suffix)
+ return next(iter(candidates)) if len(candidates) == 1 else None
+
+
+def _candidate_prefix_pairs(
+ original_input: str, runtime_input: str, output_path: str
+) -> tuple[set[tuple[str, str]], bool]:
+ """Return safe prefix substitutions supported by one input/output pair."""
+
+ original = PurePosixPath(original_input).name
+ runtime = Path(runtime_input).name
+ boundaries = [
+ index
+ for index, character in enumerate(original)
+ if character in _PREFIX_DELIMITERS and index > 0
+ ]
+ boundaries.append(len(original))
+ pairs: set[tuple[str, str]] = set()
+ invalid = False
+ for boundary in boundaries:
+ old_prefix = original[:boundary]
+ if not _output_has_prefix(output_path, old_prefix):
+ continue
+ suffix = original[boundary:]
+ if suffix and not runtime.endswith(suffix):
+ continue
+ new_prefix = runtime[: -len(suffix)] if suffix else runtime
+ if not _validate_prefix(old_prefix) or not _validate_prefix(new_prefix):
+ invalid = True
+ continue
+ pairs.add((old_prefix, new_prefix))
+ return pairs, invalid
+
+
+def _participant_paths(
+ contract: dict[str, Any],
+ output: dict[str, Any],
+ all_paths: set[str],
+) -> set[str]:
+ producer_steps = set(output.get("producer_step_ids", []))
+ same_step = {
+ item["path"]
+ for item in contract.get("inputs", [])
+ if isinstance(item, dict)
+ and item.get("replaceable")
+ and item.get("step_id") in producer_steps
+ and item.get("path") in all_paths
+ }
+ return same_step or all_paths
+
+
+def resolve_runtime_bindings(
+ contract: dict[str, Any],
+ input_overrides: dict[str, Any],
+ parameter_overrides: dict[str, Any],
+ explicit_prefix: str | None = None,
+) -> dict[str, Any]:
+ """Resolve runtime inputs and explicit parameters without changing inputs.
+
+ Output-prefix propagation is evidence based. The original output prefix is
+ taken from declared output paths; the runtime prefix defaults to ``output``.
+ """
+
+ if contract.get("schema_version") != SCHEMA_VERSION:
+ return _blocked_result(
+ contract,
+ "CONTRACT_SCHEMA_UNSUPPORTED",
+ "Runtime binding requires lightweight-runtime-binding/v1",
+ )
+ if explicit_prefix is not None and not _validate_prefix(explicit_prefix):
+ return _blocked_result(
+ contract,
+ "INVALID_PREFIX",
+ "Prefix must be one ASCII-safe filename prefix",
+ )
+
+ input_bindings, input_error = _resolve_inputs(contract, input_overrides)
+ if input_error:
+ return _blocked_result(contract, **input_error)
+ parameter_bindings, parameter_error = _resolve_parameters(
+ contract, parameter_overrides
+ )
+ if parameter_error:
+ return _blocked_result(
+ contract,
+ input_bindings=input_bindings,
+ **parameter_error,
+ )
+
+ output_files = [
+ item
+ for item in contract.get("outputs", {}).get("files", [])
+ if isinstance(item, dict)
+ and isinstance(item.get("fact_id"), str)
+ and isinstance(item.get("path"), str)
+ ]
+ if not output_files:
+ stdout = contract.get("outputs", {}).get("stdout", {})
+ if isinstance(stdout, dict) and stdout.get("present"):
+ return _resolved_result(
+ contract,
+ status="resolved",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ output_bindings=[],
+ prefix_binding={"mode": "none"},
+ error=None,
+ )
+ return _blocked_result(
+ contract,
+ "OUTPUT_BINDING_AMBIGUOUS",
+ "No file output can be bound and stdout is not a declared result",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ )
+
+ runtime_by_original = {
+ binding["original_path"]: binding["runtime_path"]
+ for binding in input_bindings or []
+ }
+ participant_options: list[set[tuple[str, str]]] = []
+ participating_paths: set[str] = set()
+ for output in output_files:
+ eligible_paths = _participant_paths(
+ contract, output, set(runtime_by_original)
+ )
+ options_for_output: list[tuple[str, set[tuple[str, str]]]] = []
+ invalid = False
+ for original_path in sorted(eligible_paths):
+ pairs, unsafe = _candidate_prefix_pairs(
+ original_path,
+ runtime_by_original[original_path],
+ output["path"],
+ )
+ invalid = invalid or unsafe
+ if pairs:
+ options_for_output.append((original_path, pairs))
+ if not options_for_output and eligible_paths != set(runtime_by_original):
+ for original_path in sorted(runtime_by_original):
+ pairs, unsafe = _candidate_prefix_pairs(
+ original_path,
+ runtime_by_original[original_path],
+ output["path"],
+ )
+ invalid = invalid or unsafe
+ if pairs:
+ options_for_output.append((original_path, pairs))
+ if invalid:
+ return _blocked_result(
+ contract,
+ "INVALID_PREFIX",
+ "An input-derived prefix is not an ASCII-safe filename prefix",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ )
+ for original_path, pairs in options_for_output:
+ participating_paths.add(original_path)
+ participant_options.append(pairs)
+
+ common_pairs = (
+ set.intersection(*participant_options) if participant_options else set()
+ )
+ if common_pairs:
+ old_prefix = max(
+ common_pairs, key=lambda pair: (len(pair[0]), len(pair[1]), pair)
+ )[0]
+ elif participant_options:
+ common_old = set.intersection(
+ *[{old for old, _new in options} for options in participant_options]
+ )
+ if not common_old:
+ old_prefix = _shared_output_prefix(output_files)
+ else:
+ old_prefix = max(common_old, key=lambda value: (len(value), value))
+ else:
+ old_prefix = _shared_output_prefix(output_files)
+ if old_prefix is None:
+ return _blocked_result(
+ contract,
+ "OUTPUT_BINDING_AMBIGUOUS",
+ "Declared file outputs do not share one safe output prefix",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ )
+ new_prefix = explicit_prefix or "output"
+ direct_output_fact_ids = {
+ fact_id
+ for target in contract.get("patch_targets", [])
+ if isinstance(target, dict)
+ and target.get("patch_kind") in {"output_path", "output_reference_path"}
+ for fact_id in target.get("fact_ids", [])
+ if isinstance(fact_id, str)
+ }
+ output_bindings = []
+ for output in output_files:
+ original_path = output["path"]
+ runtime_path = _runtime_input_owned_output(
+ original_path,
+ runtime_by_original,
+ allow_sidecar=output["fact_id"] not in direct_output_fact_ids,
+ )
+ if runtime_path is None:
+ runtime_path = (
+ _replace_output_prefix(original_path, old_prefix, new_prefix)
+ if _output_has_prefix(original_path, old_prefix)
+ else original_path
+ )
+ output_bindings.append(
+ {
+ "fact_id": output["fact_id"],
+ "original_path": original_path,
+ "runtime_path": runtime_path,
+ "transformed": runtime_path != original_path,
+ }
+ )
+ runtime_output_paths: dict[str, str] = {}
+ for binding in output_bindings:
+ runtime_path = binding["runtime_path"]
+ if runtime_path in runtime_output_paths:
+ return _blocked_result(
+ contract,
+ "OUTPUT_PATH_COLLISION",
+ "Two declared outputs resolve to the same runtime path",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ runtime_path=runtime_path,
+ fact_ids=sorted(
+ [runtime_output_paths[runtime_path], binding["fact_id"]]
+ ),
+ )
+ runtime_output_paths[runtime_path] = binding["fact_id"]
+ return _resolved_result(
+ contract,
+ status="resolved",
+ input_bindings=input_bindings,
+ parameter_bindings=parameter_bindings,
+ output_bindings=output_bindings,
+ prefix_binding={
+ "mode": "explicit" if explicit_prefix is not None else "default",
+ "old_prefix": old_prefix,
+ "new_prefix": new_prefix,
+ "participating_inputs": sorted(participating_paths),
+ },
+ error=None,
+ )
+
+
+_SHELL_WORD_EDGE = r"A-Za-z0-9_./:+-"
+
+
+def _safe_runtime_relative(value: Any, *, field: str) -> str:
+ if not isinstance(value, str) or not value or _has_control(value):
+ raise RuntimeBindingError(
+ "UNSAFE_RELATIVE_PATH", f"{field} must be a non-empty relative path"
+ )
+ path = PurePosixPath(value)
+ if path.is_absolute() or value.startswith("/") or ".." in path.parts:
+ raise RuntimeBindingError(
+ "UNSAFE_RELATIVE_PATH", f"{field} escapes the runtime directory: {value}"
+ )
+ if str(path) in {"", "."}:
+ raise RuntimeBindingError(
+ "UNSAFE_RELATIVE_PATH", f"{field} is not a file path: {value}"
+ )
+ return str(path)
+
+
+def _resolved_digest(resolved: dict[str, Any]) -> str:
+ return _digest_without(resolved, "binding_digest")
+
+
+def _package_inventory_from_contract(
+ contract: dict[str, Any],
+) -> dict[str, dict[str, Any]]:
+ inventory: dict[str, dict[str, Any]] = {}
+ for record in contract.get("package_files", []):
+ if not isinstance(record, dict):
+ raise RuntimeBindingError(
+ "PACKAGE_INVENTORY_INVALID", "package file record must be an object"
+ )
+ relative = _safe_runtime_relative(record.get("path"), field="package path")
+ if relative in inventory:
+ raise RuntimeBindingError(
+ "PACKAGE_INVENTORY_INVALID", f"duplicate package path: {relative}"
+ )
+ inventory[relative] = {
+ key: record.get(key)
+ for key in ("kind", "size", "sha256", "source")
+ }
+ return inventory
+
+
+def _validate_contract_digest(contract: dict[str, Any]) -> None:
+ if contract.get("contract_digest") != _digest_without(
+ contract, "contract_digest"
+ ):
+ raise RuntimeBindingError(
+ "CONTRACT_DIGEST_MISMATCH", "runtime binding contract was modified"
+ )
+ inventory = _package_inventory_from_contract(contract)
+ if contract.get("package_digest") != _sha256_json(inventory):
+ raise RuntimeBindingError(
+ "PACKAGE_DIGEST_MISMATCH",
+ "package digest does not match the locked package inventory",
+ )
+
+
+def _canonical_resolution(
+ contract: dict[str, Any], resolved: dict[str, Any]
+) -> dict[str, Any]:
+ input_overrides = {
+ str(item.get("original_path") or ""): item.get("runtime_path")
+ for item in resolved.get("input_bindings", [])
+ if isinstance(item, dict)
+ }
+ parameter_overrides = {
+ str(item.get("fact_id") or ""): item.get("runtime_value")
+ for item in resolved.get("parameter_bindings", [])
+ if isinstance(item, dict)
+ }
+ prefix = resolved.get("prefix_binding")
+ explicit_prefix = (
+ prefix.get("new_prefix")
+ if isinstance(prefix, dict) and prefix.get("mode") == "explicit"
+ else None
+ )
+ return resolve_runtime_bindings(
+ contract,
+ input_overrides,
+ parameter_overrides,
+ explicit_prefix=explicit_prefix,
+ )
+
+
+def _validate_render_inputs(
+ contract: dict[str, Any], raw_code: Path, resolved: dict[str, Any]
+) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
+ _validate_contract_digest(contract)
+ if resolved.get("status") != "resolved":
+ raise RuntimeBindingError(
+ "RESOLUTION_NOT_READY", "runtime bindings must have status=resolved"
+ )
+ for key in ("facts_sha256", "package_digest", "contract_digest"):
+ if resolved.get(key) != contract.get(key):
+ raise RuntimeBindingError(
+ "RESOLUTION_CONTRACT_MISMATCH",
+ f"resolved {key} does not match the binding contract",
+ )
+ if resolved.get("binding_digest") != _resolved_digest(resolved):
+ raise RuntimeBindingError(
+ "RESOLUTION_DIGEST_MISMATCH", "resolved binding digest is invalid"
+ )
+ if _canonical_resolution(contract, resolved) != resolved:
+ raise RuntimeBindingError(
+ "RESOLUTION_CANONICAL_MISMATCH",
+ "resolved bindings do not match deterministic resolution",
+ )
+
+ declared: dict[str, dict[str, Any]] = {}
+ for record in contract.get("package_files", []):
+ if not isinstance(record, dict) or record.get("source") != "raw_code":
+ continue
+ relative = _safe_runtime_relative(record.get("path"), field="code path")
+ if relative in declared:
+ raise RuntimeBindingError(
+ "SOURCE_INVENTORY_INVALID", f"duplicate code path: {relative}"
+ )
+ declared[relative] = record
+
+ raw_code = Path(raw_code)
+ actual = {
+ path.relative_to(raw_code).as_posix(): path
+ for path in sorted(raw_code.rglob("*"))
+ if path.is_file()
+ }
+ if set(actual) != set(declared):
+ raise RuntimeBindingError(
+ "SOURCE_INVENTORY_MISMATCH",
+ "raw_code file set no longer matches the locked package",
+ )
+ source_hashes: dict[str, str] = {}
+ raw_code_root = raw_code.resolve()
+ for relative, source in actual.items():
+ if source.is_symlink():
+ raise RuntimeBindingError(
+ "SOURCE_SYMLINK_UNSAFE", f"raw code must not be a symlink: {relative}"
+ )
+ try:
+ source.resolve().relative_to(raw_code_root)
+ except ValueError as exc:
+ raise RuntimeBindingError(
+ "SOURCE_PATH_ESCAPE", f"raw code escapes its package: {relative}"
+ ) from exc
+ digest = _sha256_file(source)
+ if digest != declared[relative].get("sha256"):
+ raise RuntimeBindingError(
+ "SOURCE_HASH_MISMATCH", f"raw code changed after baseline: {relative}"
+ )
+ source_hashes[relative] = digest
+ return declared, source_hashes
+
+
+def _binding_replacements(
+ contract: dict[str, Any], resolved: dict[str, Any]
+) -> tuple[dict[str, str], dict[str, dict[str, list[str]]]]:
+ requested: dict[str, str] = {}
+ patchable = {
+ target.get("binding_key")
+ for target in contract.get("patch_targets", [])
+ if isinstance(target, dict)
+ }
+ patched_path_roots: list[tuple[str, str]] = []
+
+ def add(binding_key: str, original: str, replacement: str) -> None:
+ if replacement == original:
+ return
+ previous = requested.get(binding_key)
+ if previous is not None and previous != replacement:
+ raise RuntimeBindingError(
+ "PATCH_REPLACEMENT_CONFLICT",
+ f"one binding resolves to different values: {binding_key}",
+ )
+ requested[binding_key] = replacement
+
+ for binding in resolved.get("input_bindings", []):
+ original = str(binding.get("original_path") or "")
+ runtime = Path(str(binding.get("runtime_path") or ""))
+ binding_key = f"path:{original}"
+ replacement = runtime.name
+ add(binding_key, original, replacement)
+ if replacement != original and binding_key in patchable:
+ patched_path_roots.append((original, replacement))
+
+ prefix = resolved.get("prefix_binding")
+ if isinstance(prefix, dict):
+ old_prefix = prefix.get("old_prefix")
+ new_prefix = prefix.get("new_prefix")
+ if isinstance(old_prefix, str) and isinstance(new_prefix, str):
+ for reference in contract.get("path_references", []):
+ if not isinstance(reference, dict):
+ continue
+ fact_id = str(reference.get("fact_id") or "")
+ original = str(reference.get("path") or "")
+ if not fact_id or not _output_has_prefix(original, old_prefix):
+ continue
+ replacement = _replace_output_prefix(
+ original, old_prefix, new_prefix
+ )
+ binding_key = f"path_reference:{fact_id}"
+ add(binding_key, original, replacement)
+ if replacement != original and binding_key in patchable:
+ patched_path_roots.append((original, replacement))
+
+ def covered_by_patched_root(original: str, runtime: str) -> bool:
+ for old_root, new_root in patched_path_roots:
+ if original == old_root:
+ suffix = ""
+ elif (
+ original.startswith(old_root)
+ and len(original) > len(old_root)
+ and original[len(old_root)] in _PREFIX_DELIMITERS
+ ):
+ suffix = original[len(old_root) :]
+ else:
+ continue
+ if runtime == new_root + suffix:
+ return True
+ return False
+
+ for binding in resolved.get("output_bindings", []):
+ original = str(binding.get("original_path") or "")
+ runtime = _safe_runtime_relative(
+ binding.get("runtime_path"), field="runtime output path"
+ )
+ binding_key = f"path:{original}"
+ if runtime == original:
+ continue
+ if binding_key in patchable:
+ add(binding_key, original, runtime)
+ elif covered_by_patched_root(original, runtime):
+ continue
+ else:
+ add(binding_key, original, runtime)
+ for binding in resolved.get("parameter_bindings", []):
+ fact_id = str(binding.get("fact_id") or "")
+ original = str(binding.get("original_value") or "")
+ runtime = str(binding.get("runtime_value") or "")
+ if not fact_id or not runtime or _has_control(runtime):
+ raise RuntimeBindingError(
+ "PARAMETER_VALUE_INVALID", f"unsafe runtime parameter: {fact_id}"
+ )
+ add(f"parameter:{fact_id}", original, runtime)
+
+ targets_by_key = {
+ str(target.get("binding_key") or ""): target
+ for section in ("patch_targets", "unpatchable_targets")
+ for target in contract.get(section, [])
+ if isinstance(target, dict) and target.get("binding_key")
+ }
+ patchable_by_literal: dict[str, list[str]] = {}
+ for target in contract.get("patch_targets", []):
+ if not isinstance(target, dict):
+ continue
+ key = str(target.get("binding_key") or "")
+ literal = str(target.get("original_literal") or "")
+ if key and literal:
+ patchable_by_literal.setdefault(literal, []).append(key)
+
+ alias_coverage: dict[str, dict[str, list[str]]] = {}
+ for binding_key in list(requested):
+ if binding_key not in patchable:
+ target = targets_by_key.get(binding_key, {})
+ literal = str(target.get("original_literal") or "")
+ candidates = [
+ key
+ for key in patchable_by_literal.get(literal, [])
+ if key in requested
+ ]
+ matching = [
+ key
+ for key in candidates
+ if requested[key] == requested[binding_key]
+ ]
+ if matching:
+ owner = sorted(matching)[0]
+ evidence = alias_coverage.setdefault(
+ owner,
+ {"covered_binding_keys": [], "covered_fact_ids": []},
+ )
+ evidence["covered_binding_keys"].append(binding_key)
+ evidence["covered_fact_ids"].extend(
+ str(fact_id)
+ for fact_id in target.get("fact_ids", [])
+ if isinstance(fact_id, str)
+ )
+ del requested[binding_key]
+ continue
+ if candidates:
+ raise RuntimeBindingError(
+ "PATCH_REPLACEMENT_CONFLICT",
+ f"same literal resolves to different values: {literal}",
+ )
+ raise RuntimeBindingError(
+ "PATCH_TARGET_UNAVAILABLE",
+ f"changed binding has no safe shell patch target: {binding_key}",
+ )
+ for evidence in alias_coverage.values():
+ evidence["covered_binding_keys"] = sorted(
+ set(evidence["covered_binding_keys"])
+ )
+ evidence["covered_fact_ids"] = sorted(set(evidence["covered_fact_ids"]))
+ return requested, alias_coverage
+
+
+def _shell_word_pattern(literal: str) -> re.Pattern[str]:
+ return re.compile(
+ rf"(?['\"]?){re.escape(literal)}(?P=quote)"
+ rf"(?![{_SHELL_WORD_EDGE}])"
+ )
+
+
+def _script_positional_args(contract: dict[str, Any], script: str) -> list[str]:
+ try:
+ tokens = shlex.split(str(contract.get("original_command") or ""))
+ except ValueError as exc:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", "entry command cannot be tokenized"
+ ) from exc
+ normalized_script = str(PurePosixPath(script))
+ for index, token in enumerate(tokens):
+ normalized_token = str(PurePosixPath(token))
+ while normalized_token.startswith("./"):
+ normalized_token = normalized_token[2:]
+ if normalized_token == normalized_script:
+ return tokens[index + 1 :]
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH",
+ f"patch script is not the locked entry script: {script}",
+ )
+
+
+def _anchor_command(
+ commands: list[str], anchor: dict[str, Any], literal: str
+) -> tuple[int, int]:
+ step_id = anchor.get("step_id")
+ match = re.fullmatch(r"step_(\d+)", str(step_id or ""))
+ if match is None:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", f"invalid patch step: {step_id}"
+ )
+ step_index = int(match.group(1)) - 1
+ if step_index < 0 or step_index >= len(commands):
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", f"patch step is missing: {step_id}"
+ )
+ command = commands[step_index]
+ digest = hashlib.sha256(command.encode("utf-8")).hexdigest()
+ if digest != anchor.get("command_digest"):
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", f"patch command changed: {step_id}"
+ )
+ try:
+ token_index = int(anchor.get("token_index"))
+ tokens = shlex.split(command)
+ except (TypeError, ValueError) as exc:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", f"patch token is invalid: {step_id}"
+ ) from exc
+ if token_index < 0 or token_index >= len(tokens) or tokens[token_index] != literal:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH",
+ f"patch literal is not at its locked token: {step_id}:{token_index}",
+ )
+ return step_index, token_index
+
+
+def _validate_patch_anchors_before(
+ commands: list[str], targets: list[dict[str, Any]]
+) -> None:
+ for target in targets:
+ literal = str(target.get("original_literal") or "")
+ anchors = target.get("anchors")
+ if not isinstance(anchors, list) or not anchors:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH",
+ f"patch target has no command anchor: {literal}",
+ )
+ for anchor in anchors:
+ if not isinstance(anchor, dict):
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", f"invalid patch anchor: {literal}"
+ )
+ _anchor_command(commands, anchor, literal)
+
+
+def _validate_patch_anchors_after(
+ before: list[str],
+ after: list[str],
+ targets: list[dict[str, Any]],
+ replacements: dict[str, str],
+) -> None:
+ if len(after) != len(before):
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", "patch changed the logical command count"
+ )
+ for target in targets:
+ replacement = replacements[str(target["binding_key"])]
+ for anchor in target["anchors"]:
+ match = re.fullmatch(r"step_(\d+)", str(anchor.get("step_id") or ""))
+ if match is None:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", "invalid post-patch step"
+ )
+ step_index = int(match.group(1)) - 1
+ token_index = int(anchor.get("token_index"))
+ try:
+ tokens = shlex.split(after[step_index])
+ except (IndexError, ValueError) as exc:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH", "post-patch command cannot be tokenized"
+ ) from exc
+ if token_index >= len(tokens) or tokens[token_index] != replacement:
+ raise RuntimeBindingError(
+ "PATCH_ANCHOR_MISMATCH",
+ "patched value is not at the locked command token",
+ )
+
+
+def _render_script_updates(
+ contract: dict[str, Any],
+ raw_code: Path,
+ replacements: dict[str, str],
+ alias_coverage: dict[str, dict[str, list[str]]],
+) -> tuple[dict[str, str], list[dict[str, Any]]]:
+ selected = [
+ target
+ for target in contract.get("patch_targets", [])
+ if isinstance(target, dict)
+ and target.get("binding_key") in replacements
+ ]
+ by_literal: dict[str, str] = {}
+ for target in selected:
+ literal = str(target.get("original_literal") or "")
+ replacement = replacements[str(target["binding_key"])]
+ previous = by_literal.get(literal)
+ if previous is not None and previous != replacement:
+ raise RuntimeBindingError(
+ "PATCH_REPLACEMENT_CONFLICT",
+ f"literal has conflicting runtime values: {literal}",
+ )
+ by_literal[literal] = replacement
+
+ grouped: dict[str, list[dict[str, Any]]] = {}
+ for target in selected:
+ script = _safe_runtime_relative(
+ target.get("script_path"), field="patch script path"
+ )
+ grouped.setdefault(script, []).append(target)
+
+ rendered: dict[str, str] = {}
+ patch_diff: list[dict[str, Any]] = []
+ for script, targets in sorted(grouped.items()):
+ source = raw_code / script
+ if not source.is_file():
+ raise RuntimeBindingError(
+ "PATCH_TARGET_MISSING", f"patch script does not exist: {script}"
+ )
+ source_hash = _sha256_file(source)
+ if any(target.get("script_hash") != source_hash for target in targets):
+ raise RuntimeBindingError(
+ "SOURCE_HASH_MISMATCH", f"patch script hash changed: {script}"
+ )
+ text = source.read_text(encoding="utf-8", errors="replace")
+ positional_args = _script_positional_args(contract, script)
+ before_commands = _prepare_script_commands(text, positional_args)
+ _validate_patch_anchors_before(before_commands, targets)
+ seen: set[tuple[str, str]] = set()
+ for target in targets:
+ literal = str(target.get("original_literal") or "")
+ replacement = replacements[str(target["binding_key"])]
+ identity = (literal, replacement)
+ if identity in seen:
+ continue
+ seen.add(identity)
+ try:
+ expected = int(target.get("expected_exact_match_count"))
+ except (TypeError, ValueError) as exc:
+ raise RuntimeBindingError(
+ "PATCH_TARGET_INVALID", f"invalid match count for {literal}"
+ ) from exc
+ if expected < 1:
+ raise RuntimeBindingError(
+ "PATCH_TARGET_INVALID", f"invalid match count for {literal}"
+ )
+ updated, count = _shell_word_pattern(literal).subn(
+ shlex.quote(replacement), text
+ )
+ if count != expected:
+ raise RuntimeBindingError(
+ "PATCH_MATCH_COUNT_MISMATCH",
+ f"expected {expected} exact matches for {literal} in {script}, found {count}",
+ )
+ text = updated
+ binding_key = str(target["binding_key"])
+ patch_diff.append(
+ {
+ "script_path": script,
+ "binding_key": binding_key,
+ "patch_kind": target.get("patch_kind"),
+ "original_literal": literal,
+ "runtime_literal": replacement,
+ "replacement_count": count,
+ **alias_coverage.get(binding_key, {}),
+ }
+ )
+ after_commands = _prepare_script_commands(text, positional_args)
+ _validate_patch_anchors_after(
+ before_commands, after_commands, targets, replacements
+ )
+ rendered[script] = text
+ return rendered, patch_diff
+
+
+def _input_materialization_plan(
+ contract: dict[str, Any], resolved: dict[str, Any], code_paths: set[str]
+) -> list[dict[str, Any]]:
+ modified_originals = {
+ str(binding.get("original_path") or "")
+ for binding in resolved.get("output_bindings", [])
+ }
+ used: set[str] = set()
+ plan: list[dict[str, Any]] = []
+ for binding in resolved.get("input_bindings", []):
+ original = str(binding.get("original_path") or "")
+ lexical_source = Path(str(binding.get("runtime_path") or ""))
+ source = lexical_source.resolve()
+ if not source.is_file():
+ raise RuntimeBindingError(
+ "INPUT_PATH_NOT_FOUND", f"runtime input does not exist: {source}"
+ )
+ destination = lexical_source.name
+ _safe_runtime_relative(destination, field="materialized input path")
+ if destination in {".", ".."} or destination.startswith("-"):
+ raise RuntimeBindingError(
+ "UNSAFE_INPUT_BASENAME",
+ f"runtime input basename is unsafe as a shell word: {destination}",
+ )
+ if destination in used or destination in code_paths:
+ raise RuntimeBindingError(
+ "RUNTIME_PATH_COLLISION",
+ f"runtime input basename collides in work directory: {destination}",
+ )
+ used.add(destination)
+ plan.append(
+ {
+ "original_path": original,
+ "runtime_path": str(lexical_source),
+ "materialized_path": destination,
+ "method": "copy" if original in modified_originals else "symlink",
+ "sha256": _sha256_file(source),
+ }
+ )
+ return plan
+
+
+def render_runtime_package(
+ contract: dict[str, Any],
+ raw_code: Path,
+ resolved: dict[str, Any],
+ run_dir: Path,
+) -> dict[str, Any]:
+ """Render a resolved binding into a job-local package without executing it."""
+
+ raw_code = Path(raw_code)
+ _declared, source_hashes = _validate_render_inputs(
+ contract, raw_code, resolved
+ )
+ materialization = _input_materialization_plan(
+ contract, resolved, set(source_hashes)
+ )
+ replacements, alias_coverage = _binding_replacements(contract, resolved)
+ script_updates, patch_diff = _render_script_updates(
+ contract, raw_code, replacements, alias_coverage
+ )
+
+ expected_outputs: list[dict[str, Any]] = []
+ materialized_by_original = {
+ item["original_path"]: item["materialized_path"]
+ for item in materialization
+ }
+ for binding in resolved.get("output_bindings", []):
+ runtime_path = _safe_runtime_relative(
+ binding.get("runtime_path"), field="runtime output path"
+ )
+ if runtime_path in source_hashes:
+ raise RuntimeBindingError(
+ "RUNTIME_PATH_COLLISION",
+ f"runtime output collides with code: {runtime_path}",
+ )
+ original_path = str(binding.get("original_path") or "")
+ input_path = materialized_by_original.get(original_path)
+ if runtime_path in set(materialized_by_original.values()) and runtime_path != input_path:
+ raise RuntimeBindingError(
+ "RUNTIME_PATH_COLLISION",
+ f"runtime output collides with another input: {runtime_path}",
+ )
+ expected_outputs.append(
+ {
+ "fact_id": binding.get("fact_id"),
+ "original_path": original_path,
+ "runtime_path": runtime_path,
+ }
+ )
+
+ command = contract.get("original_command")
+ if (
+ not isinstance(command, str)
+ or not command.strip()
+ or _has_control(command.replace("\n", ""))
+ ):
+ raise RuntimeBindingError(
+ "ENTRY_COMMAND_INVALID", "binding contract has no executable command"
+ )
+ run_dir = Path(run_dir)
+ if run_dir.exists() and any(run_dir.iterdir()):
+ raise RuntimeBindingError(
+ "RUN_DIRECTORY_NOT_EMPTY", f"runtime directory is not empty: {run_dir}"
+ )
+ work_dir = run_dir / "work"
+ work_dir.mkdir(parents=True, exist_ok=True)
+ for relative in sorted(source_hashes):
+ destination = work_dir / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(raw_code / relative, destination)
+ if _sha256_file(destination) != source_hashes[relative]:
+ raise RuntimeBindingError(
+ "SOURCE_COPY_HASH_MISMATCH",
+ f"copied code does not match its locked source: {relative}",
+ )
+ for script, text in script_updates.items():
+ (work_dir / script).write_text(text, encoding="utf-8")
+ for item in materialization:
+ destination = work_dir / item["materialized_path"]
+ source = Path(item["runtime_path"])
+ if item["method"] == "copy":
+ shutil.copy2(source, destination)
+ else:
+ destination.symlink_to(source)
+
+ locked_work_hashes = {
+ relative: _sha256_file(work_dir / relative)
+ for relative in sorted(source_hashes)
+ }
+
+ record = {
+ "schema_version": "lightweight-runtime-package/v1",
+ "status": "rendered",
+ "facts_sha256": contract.get("facts_sha256"),
+ "package_digest": contract.get("package_digest"),
+ "binding_digest": resolved.get("binding_digest"),
+ "original_command": command,
+ "actual_command": command,
+ "run_dir": str(run_dir.resolve()),
+ "work_dir": str(work_dir.resolve()),
+ "source_hashes": source_hashes,
+ "locked_work_hashes": locked_work_hashes,
+ "render_work_tree": _work_tree_manifest(work_dir),
+ "input_materialization": materialization,
+ "patch_diff": patch_diff,
+ "expected_outputs": expected_outputs,
+ "stdout_contract": deepcopy(
+ contract.get("outputs", {}).get("stdout", {})
+ ),
+ }
+ record["record_digest"] = _digest_without(record, "record_digest")
+ record_path = run_dir / "runtime_binding_record.json"
+ record_path.write_text(
+ json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return record
+
+
+def _event(event: str, **details: Any) -> dict[str, Any]:
+ return {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "event": event,
+ **details,
+ }
+
+
+_UNSAFE_ENV_KEYS = {
+ "BASH_ENV",
+ "ENV",
+ "SHELLOPTS",
+ "BASHOPTS",
+ "CDPATH",
+ "GLOBIGNORE",
+ "PS4",
+ "PROMPT_COMMAND",
+ "LD_PRELOAD",
+ "LD_AUDIT",
+}
+
+
+def _unsafe_env_key(key: str) -> bool:
+ return key in _UNSAFE_ENV_KEYS or key.startswith("BASH_FUNC_")
+
+
+def _validate_tool_path(value: str, *, filter_invalid: bool) -> list[str]:
+ entries: list[str] = []
+ for raw_entry in value.split(os.pathsep):
+ valid = bool(raw_entry) and not _has_control(raw_entry)
+ path = Path(raw_entry) if valid else None
+ valid = bool(path is not None and path.is_absolute())
+ try:
+ resolved = path.resolve(strict=True) if valid and path is not None else None
+ except OSError:
+ resolved = None
+ valid = bool(resolved is not None and resolved.is_dir())
+ if not valid:
+ if filter_invalid:
+ continue
+ raise RuntimeBindingError(
+ "UNSAFE_TOOL_PATH",
+ f"PATH entry must be one existing absolute directory: {raw_entry!r}",
+ )
+ canonical = str(resolved)
+ if canonical not in entries:
+ entries.append(canonical)
+ if not entries:
+ raise RuntimeBindingError(
+ "UNSAFE_TOOL_PATH", "PATH contains no valid absolute tool directory"
+ )
+ return entries
+
+
+def _process_environment(
+ env: dict[str, str] | None,
+) -> tuple[dict[str, str], list[str], str]:
+ supplied = {str(key): str(value) for key, value in (env or {}).items()}
+ unsafe = sorted(key for key in supplied if _unsafe_env_key(key))
+ if unsafe:
+ raise RuntimeBindingError(
+ "UNSAFE_ENVIRONMENT",
+ f"runtime environment contains a shell injection key: {unsafe[0]}",
+ )
+ process_env = {
+ key: value for key, value in os.environ.items() if not _unsafe_env_key(key)
+ }
+ explicit_tool_path = "PATH" in supplied
+ tool_path_entries = _validate_tool_path(
+ supplied.get("PATH", process_env.get("PATH", "")),
+ filter_invalid=not explicit_tool_path,
+ )
+ process_env.update({key: value for key, value in supplied.items() if key != "PATH"})
+ process_env["PATH"] = os.pathsep.join(tool_path_entries)
+ return process_env, tool_path_entries, _sha256_json(tool_path_entries)
+
+
+def _trusted_bash() -> Path:
+ for candidate in (Path("/usr/bin/bash"), Path("/bin/bash")):
+ try:
+ resolved = candidate.resolve(strict=True)
+ except OSError:
+ continue
+ if (
+ resolved.is_file()
+ and os.access(resolved, os.X_OK)
+ and resolved.parent in {Path("/usr/bin"), Path("/bin")}
+ ):
+ return resolved
+ raise RuntimeBindingError(
+ "TRUSTED_BASH_NOT_FOUND", "no trusted absolute system Bash is available"
+ )
+
+
+def _absolute_entry_bash(command: str, bash_path: Path) -> str:
+ return re.sub(
+ r"^\s*(?:bash|/bin/bash|/usr/bin/bash)(?=\s)",
+ shlex.quote(str(bash_path)),
+ command,
+ count=1,
+ )
+
+
+def _work_tree_manifest(root: Path) -> dict[str, dict[str, Any]]:
+ manifest: dict[str, dict[str, Any]] = {}
+ for path in sorted(root.rglob("*")):
+ relative = path.relative_to(root).as_posix()
+ _safe_runtime_relative(relative, field="work tree path")
+ if path.is_symlink():
+ manifest[relative] = {
+ "type": "symlink",
+ "target": os.readlink(path),
+ }
+ elif path.is_dir():
+ manifest[relative] = {"type": "directory"}
+ elif path.is_file():
+ manifest[relative] = {
+ "type": "regular",
+ "sha256": _sha256_file(path),
+ }
+ else:
+ raise RuntimeBindingError(
+ "WORK_TREE_UNSUPPORTED",
+ f"unsupported work tree entry: {relative}",
+ )
+ return manifest
+
+
+def _verify_render_work_tree(
+ rendered_package: dict[str, Any], work_dir: Path
+) -> None:
+ if _work_tree_manifest(work_dir) != rendered_package.get("render_work_tree"):
+ raise RuntimeBindingError(
+ "WORK_TREE_CHANGED",
+ "runtime work tree changed after package rendering",
+ )
+
+
+def _load_and_validate_render_record(
+ rendered_package: dict[str, Any],
+) -> tuple[Path, Path]:
+ if (
+ rendered_package.get("schema_version")
+ != "lightweight-runtime-package/v1"
+ or rendered_package.get("status") != "rendered"
+ ):
+ raise RuntimeBindingError(
+ "RENDERED_PACKAGE_INVALID", "runtime package is not ready to execute"
+ )
+ run_dir = Path(str(rendered_package.get("run_dir") or ""))
+ work_dir = Path(str(rendered_package.get("work_dir") or ""))
+ if (
+ not work_dir.is_dir()
+ or work_dir.resolve() != (run_dir.resolve() / "work")
+ ):
+ raise RuntimeBindingError(
+ "RENDERED_PACKAGE_INVALID", "work directory is missing or detached"
+ )
+ record_path = run_dir / "runtime_binding_record.json"
+ try:
+ disk_record = json.loads(record_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise RuntimeBindingError(
+ "RECORD_MISSING", "runtime binding record cannot be loaded"
+ ) from exc
+ if disk_record.get("record_digest") != _digest_without(
+ disk_record, "record_digest"
+ ):
+ raise RuntimeBindingError(
+ "RECORD_DIGEST_MISMATCH", "runtime binding record was modified"
+ )
+ if rendered_package.get("record_digest") != _digest_without(
+ rendered_package, "record_digest"
+ ):
+ raise RuntimeBindingError(
+ "RECORD_DIGEST_MISMATCH", "in-memory runtime binding record is invalid"
+ )
+ if disk_record != rendered_package:
+ raise RuntimeBindingError(
+ "RECORD_MISMATCH", "disk and in-memory runtime binding records differ"
+ )
+ if rendered_package.get("actual_command") != rendered_package.get(
+ "original_command"
+ ):
+ raise RuntimeBindingError(
+ "ENTRY_COMMAND_CHANGED", "actual command differs from the locked entry command"
+ )
+ return run_dir, work_dir
+
+
+def _verify_locked_work(rendered_package: dict[str, Any], work_dir: Path) -> None:
+ for relative, expected in rendered_package.get("locked_work_hashes", {}).items():
+ safe = _safe_runtime_relative(relative, field="locked work path")
+ path = work_dir / safe
+ if not path.is_file() or path.is_symlink() or _sha256_file(path) != expected:
+ raise RuntimeBindingError(
+ "WORK_PACKAGE_CHANGED", f"locked work file changed: {safe}"
+ )
+
+
+def _verify_runtime_inputs(
+ rendered_package: dict[str, Any], work_dir: Path, *, after: bool
+) -> None:
+ for item in rendered_package.get("input_materialization", []):
+ source = Path(str(item.get("runtime_path") or ""))
+ materialized = work_dir / _safe_runtime_relative(
+ item.get("materialized_path"), field="materialized input path"
+ )
+ expected = item.get("sha256")
+ if not source.is_file() or _sha256_file(source) != expected:
+ raise RuntimeBindingError(
+ "INPUT_CONTENT_CHANGED",
+ f"runtime input content changed: {source}",
+ )
+ if after:
+ continue
+ if not materialized.is_file() or _sha256_file(materialized) != expected:
+ raise RuntimeBindingError(
+ "INPUT_CONTENT_CHANGED",
+ f"materialized input content changed: {materialized}",
+ )
+
+
+def _isolate_runtime_inputs(
+ rendered_package: dict[str, Any], work_dir: Path
+) -> list[dict[str, str]]:
+ execution_inputs: list[dict[str, str]] = []
+ for item in rendered_package.get("input_materialization", []):
+ source = Path(str(item.get("runtime_path") or ""))
+ relative = _safe_runtime_relative(
+ item.get("materialized_path"), field="materialized input path"
+ )
+ destination = work_dir / relative
+ if item.get("method") == "symlink":
+ file_descriptor, temporary_name = tempfile.mkstemp(
+ prefix=".bbav5-input-", dir=destination.parent
+ )
+ os.close(file_descriptor)
+ temporary = Path(temporary_name)
+ try:
+ shutil.copy2(source, temporary)
+ if _sha256_file(temporary) != item.get("sha256"):
+ raise RuntimeBindingError(
+ "INPUT_CONTENT_CHANGED",
+ f"isolated input copy does not match source: {source}",
+ )
+ os.replace(temporary, destination)
+ finally:
+ if temporary.exists():
+ temporary.unlink()
+ elif item.get("method") != "copy":
+ raise RuntimeBindingError(
+ "RENDERED_PACKAGE_INVALID",
+ f"unknown input materialization method: {item.get('method')}",
+ )
+ if not destination.is_file() or destination.is_symlink():
+ raise RuntimeBindingError(
+ "INPUT_ISOLATION_FAILED", f"input is not an isolated copy: {relative}"
+ )
+ execution_inputs.append(
+ {
+ "materialized_path": relative,
+ "mode": "isolated_copy",
+ "source_path": str(source),
+ }
+ )
+ return execution_inputs
+
+
+def replay_runtime_binding(
+ rendered_package: dict[str, Any],
+ env: dict[str, str] | None = None,
+ on_process_start: Callable[[ProcessIdentity], None] | None = None,
+) -> dict[str, Any]:
+ """Execute one rendered package and validate only its declared results."""
+
+ run_dir, work_dir = _load_and_validate_render_record(rendered_package)
+ command = rendered_package.get("actual_command")
+ if not isinstance(command, str) or not command.strip():
+ raise RuntimeBindingError(
+ "ENTRY_COMMAND_INVALID", "rendered package has no actual command"
+ )
+ process_env, tool_path_entries, tool_path_digest = _process_environment(env)
+ bash_path = _trusted_bash()
+ execution_command = _absolute_entry_bash(command, bash_path)
+ _verify_locked_work(rendered_package, work_dir)
+ _verify_render_work_tree(rendered_package, work_dir)
+ _verify_runtime_inputs(rendered_package, work_dir, after=False)
+ execution_inputs = _isolate_runtime_inputs(rendered_package, work_dir)
+ _verify_runtime_inputs(rendered_package, work_dir, after=True)
+ execution_work_tree = _work_tree_manifest(work_dir)
+
+ before = inventory_files(work_dir)
+ logs_dir = run_dir / "logs"
+ logs_dir.mkdir(parents=True, exist_ok=True)
+ stdout_path = logs_dir / "stdout.log"
+ stderr_path = logs_dir / "stderr.log"
+ events_path = logs_dir / "execution_events.jsonl"
+ event_start = _event(
+ "start",
+ command=execution_command,
+ bash_path=str(bash_path),
+ tool_path_digest=tool_path_digest,
+ tool_path_entries=tool_path_entries,
+ execution_inputs=execution_inputs,
+ execution_work_tree=execution_work_tree,
+ )
+ with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle:
+ process, process_identity = start_managed_process(
+ [str(bash_path), "-c", execution_command],
+ cwd=work_dir,
+ env=process_env,
+ stdout=stdout_handle,
+ stderr=stderr_handle,
+ )
+ event_start["process"] = process_identity
+ events_path.write_text(
+ json.dumps(event_start, ensure_ascii=False) + "\n", encoding="utf-8"
+ )
+ try:
+ if on_process_start is not None:
+ on_process_start(dict(process_identity))
+ except BaseException:
+ stop_process_group(process_identity)
+ process.wait()
+ raise
+ returncode = process.wait()
+ after = inventory_files(work_dir)
+ missing_outputs: list[str] = []
+ outputs: list[dict[str, Any]] = []
+ for expected in rendered_package.get("expected_outputs", []):
+ relative = _safe_runtime_relative(
+ expected.get("runtime_path"), field="expected output path"
+ )
+ record = after.get(relative)
+ if record is None or (
+ relative in before
+ and before[relative].get("sha256") == record.get("sha256")
+ ):
+ missing_outputs.append(relative)
+ continue
+ outputs.append(
+ {
+ "fact_id": expected.get("fact_id"),
+ "runtime_path": relative,
+ "status": "modified" if relative in before else "new",
+ "size": record["size"],
+ "sha256": record["sha256"],
+ }
+ )
+ stdout_required = bool(
+ rendered_package.get("stdout_contract", {}).get("present")
+ )
+ stdout_ok = not stdout_required or stdout_path.stat().st_size > 0
+ success = returncode == 0
+ post_input_error: RuntimeBindingError | None = None
+ try:
+ _verify_runtime_inputs(rendered_package, work_dir, after=True)
+ except RuntimeBindingError as exc:
+ post_input_error = exc
+ contract_passed = bool(
+ success and not missing_outputs and stdout_ok and post_input_error is None
+ )
+ if post_input_error is not None:
+ failure_type = post_input_error.code
+ elif not success:
+ failure_type = "execution_failed"
+ elif missing_outputs:
+ failure_type = "missing_outputs"
+ elif not stdout_ok:
+ failure_type = "stdout_missing"
+ else:
+ failure_type = None
+ result_path = run_dir / "runtime_execution_result.json"
+ result = {
+ "success": success,
+ "returncode": returncode,
+ "process": process_identity,
+ "contract_passed": contract_passed,
+ "missing_outputs": missing_outputs,
+ "outputs": outputs,
+ "stdout_contract_passed": stdout_ok,
+ "failure_type": failure_type,
+ "bash_path": str(bash_path),
+ "execution_command": execution_command,
+ "tool_path_digest": tool_path_digest,
+ "tool_path_entries": tool_path_entries,
+ "execution_inputs": execution_inputs,
+ "execution_work_tree": execution_work_tree,
+ "before_files": before,
+ "after_files": after,
+ "logs": {
+ "stdout": str(stdout_path),
+ "stderr": str(stderr_path),
+ "events": str(events_path),
+ "result": str(result_path),
+ },
+ }
+ result_path.write_text(
+ json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ with events_path.open("a", encoding="utf-8") as handle:
+ handle.write(
+ json.dumps(
+ _event(
+ "finish",
+ returncode=returncode,
+ contract_passed=contract_passed,
+ missing_outputs=missing_outputs,
+ failure_type=failure_type,
+ bash_path=str(bash_path),
+ tool_path_digest=tool_path_digest,
+ tool_path_entries=tool_path_entries,
+ execution_inputs=execution_inputs,
+ execution_work_tree=execution_work_tree,
+ ),
+ ensure_ascii=False,
+ )
+ + "\n"
+ )
+ if post_input_error is not None:
+ raise post_input_error
+ return result
diff --git a/gfa_core/extension_stage_lightweight/semantic_model.py b/gfa_core/extension_stage_lightweight/semantic_model.py
new file mode 100644
index 000000000..325826fa1
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/semantic_model.py
@@ -0,0 +1,410 @@
+"""Validate semantic annotations without allowing execution-fact rewrites."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+
+_TOP_LEVEL_FIELDS = {
+ "facts_sha256",
+ "function_summary",
+ "inputs",
+ "parameters",
+ "outputs",
+}
+_SECTION_FIELDS = {
+ "inputs": {
+ "fact_id",
+ "role",
+ "description",
+ "replaceability",
+ },
+ "parameters": {
+ "fact_id",
+ "display_name",
+ "description",
+ },
+ "outputs": {
+ "fact_id",
+ "description",
+ "result_role",
+ },
+}
+_ENUMS = {
+ ("inputs", "replaceability"): {
+ "replaceable",
+ "fixed_resource",
+ "generated",
+ },
+ ("outputs", "result_role"): {"final", "intermediate", "log", "unknown"},
+}
+
+
+class SemanticCandidateError(ValueError):
+ """Raised when a semantic candidate does not satisfy its fact contract."""
+
+ def __init__(self, errors: list[dict[str, str]]) -> None:
+ self.errors = deepcopy(errors)
+ summary = "; ".join(error["message"] for error in errors)
+ super().__init__(f"Invalid semantic candidate: {summary}")
+
+
+def _error(field: str, code: str, message: str) -> dict[str, str]:
+ return {"field": field, "code": code, "message": message}
+
+
+def _expected_fact_ids(facts: dict[str, Any]) -> dict[str, set[str]]:
+ expected = {"inputs": set(), "parameters": set(), "outputs": set()}
+ for command in facts.get("commands", []):
+ expected["inputs"].update(
+ item["fact_id"] for item in command.get("inputs", [])
+ )
+ expected["parameters"].update(
+ item["fact_id"] for item in command.get("parameters", [])
+ )
+ expected["outputs"].update(
+ item["fact_id"] for item in facts.get("outputs", {}).get("files", [])
+ )
+ stdout = facts.get("outputs", {}).get("stdout", {})
+ if stdout.get("present"):
+ expected["outputs"].add(stdout["fact_id"])
+ return expected
+
+
+def _fact_records_by_section(facts: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
+ records = {
+ "inputs": [
+ item
+ for command in facts.get("commands", [])
+ for item in command.get("inputs", [])
+ ],
+ "parameters": [
+ item
+ for command in facts.get("commands", [])
+ for item in command.get("parameters", [])
+ ],
+ "outputs": list(facts.get("outputs", {}).get("files", [])),
+ }
+ stdout = facts.get("outputs", {}).get("stdout", {})
+ if stdout.get("present"):
+ records["outputs"].append(stdout)
+ return records
+
+
+def _missing_semantic_row(section: str, fact: dict[str, Any]) -> dict[str, str]:
+ fact_id = fact["fact_id"]
+ if section == "inputs":
+ path = fact.get("path", fact_id)
+ return {
+ "fact_id": fact_id,
+ "role": f"Locked input: {path}.",
+ "description": f"Observed input file: {path}.",
+ "replaceability": (
+ "generated"
+ if fact.get("source") == "generated_input"
+ else "replaceable"
+ ),
+ }
+ if section == "parameters":
+ value = fact.get("value", fact_id)
+ return {
+ "fact_id": fact_id,
+ "display_name": f"Locked parameter: {value}.",
+ "description": f"Observed command parameter: {value}.",
+ }
+ if "path" in fact:
+ description = f"Observed output file: {fact['path']}."
+ else:
+ description = "Observed standard output."
+ return {
+ "fact_id": fact_id,
+ "description": description,
+ "result_role": "unknown",
+ }
+
+
+def bind_fact_derived_semantics(
+ facts: dict[str, Any], candidate: dict[str, Any]
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ """Bind facts the model cannot decide and discard unrepairable references."""
+
+ bound = deepcopy(candidate)
+ audit: list[dict[str, Any]] = []
+ expected = _expected_fact_ids(facts)
+ records_by_section = _fact_records_by_section(facts)
+ input_sources = {
+ item["fact_id"]: item.get("source")
+ for command in facts.get("commands", [])
+ for item in command.get("inputs", [])
+ }
+ for section in ("inputs", "parameters", "outputs"):
+ entries = bound.get(section)
+ if not isinstance(entries, list):
+ continue
+ kept: list[Any] = []
+ seen: set[str] = set()
+ discarded_reference = False
+ for entry in entries:
+ if not isinstance(entry, dict):
+ kept.append(entry)
+ continue
+ fact_id = entry.get("fact_id")
+ if isinstance(fact_id, str) and fact_id not in expected[section]:
+ discarded_reference = True
+ audit.append(
+ {
+ "action": "discard_unknown_reference",
+ "section": section,
+ "fact_id": fact_id,
+ }
+ )
+ continue
+ if isinstance(fact_id, str) and fact_id in seen:
+ discarded_reference = True
+ audit.append(
+ {
+ "action": "discard_duplicate_reference",
+ "section": section,
+ "fact_id": fact_id,
+ }
+ )
+ continue
+ if isinstance(fact_id, str):
+ seen.add(fact_id)
+ if (
+ section == "inputs"
+ and isinstance(fact_id, str)
+ and not str(entry.get("replaceability") or "").strip()
+ ):
+ bound_value = (
+ "generated"
+ if input_sources.get(fact_id) == "generated_input"
+ else "replaceable"
+ )
+ audit.append(
+ {
+ "action": "bind_missing_input_replaceability",
+ "section": section,
+ "fact_id": fact_id,
+ "field": "replaceability",
+ "bound_value": bound_value,
+ }
+ )
+ entry["replaceability"] = bound_value
+ if (
+ section == "inputs"
+ and input_sources.get(fact_id) == "generated_input"
+ and entry.get("replaceability") != "generated"
+ ):
+ audit.append(
+ {
+ "action": "bind_generated_input",
+ "section": section,
+ "fact_id": fact_id,
+ "field": "replaceability",
+ "discarded_value": entry.get("replaceability"),
+ }
+ )
+ entry["replaceability"] = "generated"
+ kept.append(entry)
+ if not discarded_reference:
+ for fact in records_by_section[section]:
+ fact_id = fact["fact_id"]
+ if fact_id in seen:
+ continue
+ kept.append(_missing_semantic_row(section, fact))
+ audit.append(
+ {
+ "action": "complete_missing_semantic_row",
+ "section": section,
+ "fact_id": fact_id,
+ }
+ )
+ bound[section] = kept
+ return bound, audit
+
+
+def _validate_text_field(
+ *,
+ item: dict[str, Any],
+ field: str,
+ path: str,
+ errors: list[dict[str, str]],
+) -> None:
+ value = item.get(field)
+ if not isinstance(value, str) or not value.strip():
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "invalid_value",
+ f"{path}.{field} must be a non-empty string",
+ )
+ )
+
+
+def validate_semantic_candidate(
+ facts: dict[str, Any], candidate: dict[str, Any]
+) -> list[dict[str, str]]:
+ """Return deterministic field errors for a semantic-only candidate."""
+
+ errors: list[dict[str, str]] = []
+ if not isinstance(candidate, dict):
+ return [
+ _error("candidate", "invalid_type", "candidate must be an object")
+ ]
+
+ for field in sorted(set(candidate) - _TOP_LEVEL_FIELDS):
+ errors.append(
+ _error(field, "unknown_field", f"candidate field is not allowed: {field}")
+ )
+ for field in sorted(_TOP_LEVEL_FIELDS - set(candidate)):
+ errors.append(
+ _error(field, "required_field", f"candidate field is required: {field}")
+ )
+
+ if candidate.get("facts_sha256") != facts.get("facts_sha256"):
+ errors.append(
+ _error(
+ "facts_sha256",
+ "facts_mismatch",
+ "candidate facts_sha256 does not match locked execution facts",
+ )
+ )
+ summary = candidate.get("function_summary")
+ if not isinstance(summary, str) or not summary.strip():
+ errors.append(
+ _error(
+ "function_summary",
+ "invalid_value",
+ "function_summary must be a non-empty string",
+ )
+ )
+
+ expected_by_section = _expected_fact_ids(facts)
+ input_sources = {
+ item["fact_id"]: item.get("source")
+ for command in facts.get("commands", [])
+ for item in command.get("inputs", [])
+ }
+ for section, allowed_fields in _SECTION_FIELDS.items():
+ entries = candidate.get(section)
+ if not isinstance(entries, list):
+ errors.append(
+ _error(section, "invalid_type", f"{section} must be an array")
+ )
+ continue
+
+ seen: set[str] = set()
+ for index, entry in enumerate(entries):
+ path = f"{section}[{index}]"
+ if not isinstance(entry, dict):
+ errors.append(
+ _error(path, "invalid_type", f"{path} must be an object")
+ )
+ continue
+ for field in sorted(set(entry) - allowed_fields):
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "unknown_field",
+ f"{path}.{field} is not allowed",
+ )
+ )
+ for field in sorted(allowed_fields - set(entry)):
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "required_field",
+ f"{path}.{field} is required",
+ )
+ )
+
+ fact_id = entry.get("fact_id")
+ if isinstance(fact_id, str) and fact_id:
+ if fact_id in seen:
+ errors.append(
+ _error(
+ f"{path}.fact_id",
+ "duplicate_fact_id",
+ f"duplicate {section} fact_id: {fact_id}",
+ )
+ )
+ else:
+ seen.add(fact_id)
+ if fact_id not in expected_by_section[section]:
+ errors.append(
+ _error(
+ f"{path}.fact_id",
+ "unknown_fact_id",
+ f"unknown {section} fact_id: {fact_id}",
+ )
+ )
+ else:
+ errors.append(
+ _error(
+ f"{path}.fact_id",
+ "invalid_value",
+ f"{path}.fact_id must be a non-empty string",
+ )
+ )
+
+ for field in allowed_fields - {"fact_id"}:
+ _validate_text_field(
+ item=entry,
+ field=field,
+ path=path,
+ errors=errors,
+ )
+ allowed_values = _ENUMS.get((section, field))
+ if allowed_values is not None and entry.get(field) not in allowed_values:
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "invalid_value",
+ f"{path}.{field} must be one of: "
+ + ", ".join(sorted(allowed_values)),
+ )
+ )
+
+ if fact_id in input_sources:
+ replaceability = entry.get("replaceability")
+ source = input_sources[fact_id]
+ source_mismatch = (
+ source == "generated_input" and replaceability != "generated"
+ ) or (
+ source != "generated_input" and replaceability == "generated"
+ )
+ if source_mismatch:
+ errors.append(
+ _error(
+ f"{path}.replaceability",
+ "input_source_mismatch",
+ f"{path}.replaceability conflicts with locked input source: "
+ f"{source}",
+ )
+ )
+
+ for fact_id in sorted(expected_by_section[section] - seen):
+ errors.append(
+ _error(
+ section,
+ "missing_fact_id",
+ f"missing {section} fact_id: {fact_id}",
+ )
+ )
+ return errors
+
+
+def merge_semantics(
+ facts: dict[str, Any], candidate: dict[str, Any]
+) -> dict[str, Any]:
+ """Combine validated semantics with an untouched copy of execution facts."""
+
+ errors = validate_semantic_candidate(facts, candidate)
+ if errors:
+ raise SemanticCandidateError(errors)
+ return {
+ "execution_facts": deepcopy(facts),
+ "semantics": deepcopy(candidate),
+ }
diff --git a/gfa_core/extension_stage_lightweight/semantic_repair.py b/gfa_core/extension_stage_lightweight/semantic_repair.py
new file mode 100644
index 000000000..dba045ede
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/semantic_repair.py
@@ -0,0 +1,376 @@
+"""Apply bounded semantic patches without changing execution facts."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from pathlib import Path
+import re
+from typing import Any, Callable, Mapping
+
+from .deepseek_semantics import (
+ DeepSeekConfig,
+ DeepSeekSemanticError,
+ generate_semantic_patch,
+)
+from .semantic_model import bind_fact_derived_semantics, validate_semantic_candidate
+
+
+MAX_REPAIR_ATTEMPTS = 20
+_FIELDS = {
+ "root": {"function_summary"},
+ "inputs": {"role", "description", "replaceability"},
+ "parameters": {"display_name", "description"},
+ "outputs": {"description", "result_role"},
+}
+_ERROR_PATH = re.compile(
+ r"^(inputs|parameters|outputs)\[(\d+)\]\.([A-Za-z_][A-Za-z0-9_]*)$"
+)
+
+
+def _error(field: str, code: str, message: str) -> dict[str, str]:
+ return {"field": field, "code": code, "message": message}
+
+
+def _expected_ids(facts: dict[str, Any]) -> dict[str, set[str]]:
+ expected = {"inputs": set(), "parameters": set(), "outputs": set()}
+ for command in facts["commands"]:
+ expected["inputs"].update(item["fact_id"] for item in command["inputs"])
+ expected["parameters"].update(
+ item["fact_id"] for item in command["parameters"]
+ )
+ expected["outputs"].update(
+ item["fact_id"] for item in facts["outputs"]["files"]
+ )
+ if facts["outputs"]["stdout"].get("present"):
+ expected["outputs"].add(facts["outputs"]["stdout"]["fact_id"])
+ return expected
+
+
+def _all_targets(facts: dict[str, Any]) -> set[tuple[str, str | None, str]]:
+ targets = {("root", None, "function_summary")}
+ for section, fact_ids in _expected_ids(facts).items():
+ targets.update(
+ (section, fact_id, field)
+ for fact_id in fact_ids
+ for field in _FIELDS[section]
+ )
+ return targets
+
+
+def _automatic_targets(
+ facts: dict[str, Any],
+ candidate: dict[str, Any],
+ errors: list[dict[str, str]],
+) -> set[tuple[str, str | None, str]]:
+ targets: set[tuple[str, str | None, str]] = set()
+ if any(error["field"] == "function_summary" for error in errors):
+ targets.add(("root", None, "function_summary"))
+
+ for error in errors:
+ match = _ERROR_PATH.match(error["field"])
+ if not match:
+ continue
+ section, raw_index, field = match.groups()
+ entries = candidate.get(section, [])
+ index = int(raw_index)
+ if index < len(entries) and field in _FIELDS[section]:
+ fact_id = entries[index].get("fact_id")
+ if isinstance(fact_id, str):
+ targets.add((section, fact_id, field))
+
+ expected = _expected_ids(facts)
+ for section, fact_ids in expected.items():
+ present = {
+ item.get("fact_id")
+ for item in candidate.get(section, [])
+ if isinstance(item, dict)
+ }
+ for fact_id in fact_ids - present:
+ targets.update(
+ (section, fact_id, field) for field in _FIELDS[section]
+ )
+ return targets
+
+
+def _targets_as_records(
+ targets: set[tuple[str, str | None, str]],
+) -> list[dict[str, str | None]]:
+ return [
+ {"section": section, "fact_id": fact_id, "field": field}
+ for section, fact_id, field in sorted(
+ targets, key=lambda item: (item[0], item[1] or "", item[2])
+ )
+ ]
+
+
+def _validate_patch(
+ *,
+ facts: dict[str, Any],
+ patch: Any,
+ allowed_targets: set[tuple[str, str | None, str]],
+) -> list[dict[str, str]]:
+ errors: list[dict[str, str]] = []
+ if not isinstance(patch, dict):
+ return [_error("patch", "invalid_type", "repair patch must be an object")]
+ required = {"facts_sha256", "changes"}
+ for field in sorted(set(patch) - required):
+ errors.append(
+ _error(field, "unknown_field", f"repair patch field is not allowed: {field}")
+ )
+ for field in sorted(required - set(patch)):
+ errors.append(_error(field, "required_field", f"repair patch requires {field}"))
+ if patch.get("facts_sha256") != facts["facts_sha256"]:
+ errors.append(
+ _error(
+ "facts_sha256",
+ "facts_mismatch",
+ "repair patch facts_sha256 does not match locked execution facts",
+ )
+ )
+ changes = patch.get("changes")
+ if not isinstance(changes, list):
+ errors.append(_error("changes", "invalid_type", "changes must be an array"))
+ return errors
+
+ expected = _expected_ids(facts)
+ seen: set[tuple[str, str | None, str]] = set()
+ for index, change in enumerate(changes):
+ path = f"changes[{index}]"
+ if not isinstance(change, dict):
+ errors.append(_error(path, "invalid_type", f"{path} must be an object"))
+ continue
+ allowed_keys = {"section", "fact_id", "fields"}
+ for field in sorted(set(change) - allowed_keys):
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "unknown_field",
+ f"{path}.{field} is not allowed",
+ )
+ )
+ if set(change) != allowed_keys:
+ for field in sorted(allowed_keys - set(change)):
+ errors.append(
+ _error(
+ f"{path}.{field}",
+ "required_field",
+ f"{path}.{field} is required",
+ )
+ )
+ continue
+
+ section = change["section"]
+ fact_id = change["fact_id"]
+ fields = change["fields"]
+ if section not in _FIELDS:
+ errors.append(
+ _error(f"{path}.section", "invalid_value", f"unknown section: {section}")
+ )
+ continue
+ if section == "root":
+ if fact_id is not None:
+ errors.append(
+ _error(
+ f"{path}.fact_id",
+ "invalid_value",
+ "root patch fact_id must be null",
+ )
+ )
+ elif not isinstance(fact_id, str) or fact_id not in expected[section]:
+ errors.append(
+ _error(
+ f"{path}.fact_id",
+ "unknown_fact_id",
+ f"unknown {section} fact_id: {fact_id}",
+ )
+ )
+ if not isinstance(fields, dict) or not fields:
+ errors.append(
+ _error(f"{path}.fields", "invalid_type", "fields must be an object")
+ )
+ continue
+ for field, value in fields.items():
+ target = (section, fact_id, field)
+ if field not in _FIELDS[section]:
+ errors.append(
+ _error(
+ f"{path}.fields.{field}",
+ "unknown_field",
+ f"semantic repair field is not allowed: {field}",
+ )
+ )
+ elif target not in allowed_targets:
+ errors.append(
+ _error(
+ f"{path}.fields.{field}",
+ "target_not_allowed",
+ f"semantic repair target is not allowed: {section}/{fact_id}/{field}",
+ )
+ )
+ elif target in seen:
+ errors.append(
+ _error(
+ f"{path}.fields.{field}",
+ "duplicate_target",
+ f"duplicate semantic repair target: {section}/{fact_id}/{field}",
+ )
+ )
+ elif not isinstance(value, str) or not value.strip():
+ errors.append(
+ _error(
+ f"{path}.fields.{field}",
+ "invalid_value",
+ f"semantic repair value must be a non-empty string: {field}",
+ )
+ )
+ else:
+ seen.add(target)
+ return errors
+
+
+def _apply_patch(
+ candidate: dict[str, Any], patch: dict[str, Any]
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ proposed = deepcopy(candidate)
+ diff: list[dict[str, Any]] = []
+ for change in patch["changes"]:
+ section = change["section"]
+ fact_id = change["fact_id"]
+ if section == "root":
+ target = proposed
+ else:
+ entries = proposed.setdefault(section, [])
+ target = next(
+ (item for item in entries if item.get("fact_id") == fact_id), None
+ )
+ if target is None:
+ target = {"fact_id": fact_id}
+ entries.append(target)
+ for field, value in sorted(change["fields"].items()):
+ before = target.get(field)
+ target[field] = value
+ if before != value:
+ diff.append(
+ {
+ "section": section,
+ "fact_id": fact_id,
+ "field": field,
+ "before": before,
+ "after": value,
+ }
+ )
+ return proposed, diff
+
+
+PatchGenerator = Callable[
+ [dict[str, Any], int], tuple[dict[str, Any], dict[str, Any]]
+]
+
+
+def repair_semantic_candidate(
+ *,
+ execution_facts: dict[str, Any],
+ candidate: dict[str, Any],
+ validation_errors: list[dict[str, str]] | None = None,
+ user_feedback: str | None = None,
+ patch_generator: PatchGenerator | None = None,
+ raw_code: Path | None = None,
+ environ: Mapping[str, str] | None = None,
+ client_factory: Callable[[DeepSeekConfig], Any] | None = None,
+ max_attempts: int = MAX_REPAIR_ATTEMPTS,
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ """Repair semantic fields while preserving the supplied candidate until success."""
+
+ original = deepcopy(candidate)
+ errors = (
+ deepcopy(validation_errors)
+ if validation_errors is not None
+ else validate_semantic_candidate(execution_facts, candidate)
+ )
+ feedback = (user_feedback or "").strip()
+ if not errors and not feedback:
+ return original, []
+ mode = "user_feedback" if feedback else "automatic"
+ allowed_targets = (
+ _all_targets(execution_facts)
+ if feedback
+ else _automatic_targets(execution_facts, candidate, errors)
+ )
+ context = {
+ "execution_facts": execution_facts,
+ "candidate": original,
+ "validation_errors": errors,
+ "user_feedback": feedback or None,
+ "allowed_targets": _targets_as_records(allowed_targets),
+ }
+ if patch_generator is None:
+ if raw_code is None:
+ raise ValueError("raw_code is required for DeepSeek semantic repair")
+
+ def patch_generator(
+ repair_context: dict[str, Any], attempt: int
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ return generate_semantic_patch(
+ repair_context=repair_context,
+ raw_code=raw_code,
+ environ=environ,
+ client_factory=client_factory,
+ attempt=attempt,
+ )
+
+ history: list[dict[str, Any]] = []
+ limit = min(MAX_REPAIR_ATTEMPTS, max(1, int(max_attempts)))
+ for attempt in range(1, limit + 1):
+ metadata: dict[str, Any] = {}
+ patch_errors: list[dict[str, str]] = []
+ diff: list[dict[str, Any]] = []
+ try:
+ patch, metadata = patch_generator(deepcopy(context), attempt)
+ patch_errors = _validate_patch(
+ facts=execution_facts,
+ patch=patch,
+ allowed_targets=allowed_targets,
+ )
+ if not patch_errors:
+ proposed, diff = _apply_patch(original, patch)
+ if mode == "automatic":
+ proposed, binding_audit = bind_fact_derived_semantics(
+ execution_facts, proposed
+ )
+ if binding_audit:
+ metadata["fact_derived_semantic_bindings"] = binding_audit
+ patch_errors = validate_semantic_candidate(
+ execution_facts, proposed
+ )
+ if not patch_errors:
+ history.append(
+ {
+ "attempt": attempt,
+ "mode": mode,
+ "status": "success",
+ "diff": diff,
+ "metadata": deepcopy(metadata),
+ "errors": [],
+ }
+ )
+ return proposed, history
+ except DeepSeekSemanticError as exc:
+ metadata = deepcopy(exc.metadata)
+ patch_errors = [_error("repair", exc.code, str(exc))]
+ except Exception as exc:
+ patch_errors = [
+ _error("repair", "patch_generation_failed", str(exc))
+ ]
+
+ status = "needs_user_confirmation" if attempt == limit else "rejected"
+ history.append(
+ {
+ "attempt": attempt,
+ "mode": mode,
+ "status": status,
+ "diff": diff,
+ "metadata": deepcopy(metadata),
+ "errors": patch_errors,
+ }
+ )
+ return original, history
diff --git a/gfa_core/extension_stage_lightweight/service.py b/gfa_core/extension_stage_lightweight/service.py
new file mode 100644
index 000000000..8c457dae5
--- /dev/null
+++ b/gfa_core/extension_stage_lightweight/service.py
@@ -0,0 +1,795 @@
+"""Persistent Phase-B orchestration for the lightweight extension core only."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from copy import deepcopy
+import fcntl
+import json
+import os
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Any, Callable, Iterator
+from uuid import uuid4
+
+from .confirmation import apply_confirmation_action, create_confirmation, is_candidate_accepted
+from .converter import build_execution_facts
+from .deepseek_semantics import generate_semantic_candidate
+from .evidence import run_raw_baseline
+from .process_control import ProcessIdentity, stop_process_group
+from .runtime_binding import (
+ build_runtime_binding_contract,
+ render_runtime_package,
+ replay_runtime_binding,
+ resolve_runtime_bindings,
+)
+from .semantic_repair import repair_semantic_candidate
+
+
+SemanticGenerator = Callable[[dict[str, Any], Path], tuple[dict[str, Any], dict[str, Any]]]
+PatchGenerator = Callable[[dict[str, Any], int], tuple[dict[str, Any], dict[str, Any]]]
+
+
+class LifecycleStateError(ValueError):
+ """The requested lifecycle action is not allowed in the current state."""
+
+
+def _copy_tree(source: Path, destination: Path) -> None:
+ if destination.exists():
+ raise ValueError(f"destination already exists: {destination}")
+ shutil.copytree(source, destination, symlinks=True)
+
+
+class LightweightExtensionService:
+ """A small state machine that delegates all conversion/runtime facts to Phase A."""
+
+ def __init__(
+ self,
+ store_root: Path,
+ *,
+ semantic_generator: SemanticGenerator | None = None,
+ patch_generator: PatchGenerator | None = None,
+ environment: dict[str, str] | None = None,
+ ) -> None:
+ self.store_root = Path(store_root)
+ self.extensions_root = self.store_root / "extensions"
+ self.semantic_generator = semantic_generator or self._deepseek_candidate
+ self.patch_generator = patch_generator
+ self.environment = environment
+
+ def extension_dir(self, extension_id: str) -> Path:
+ if (
+ not extension_id
+ or "/" in extension_id
+ or "\\" in extension_id
+ or extension_id in {".", ".."}
+ ):
+ raise ValueError("extension_id must be one safe path component")
+ return self.extensions_root / extension_id
+
+ def _record_path(self, extension_id: str) -> Path:
+ return self.extension_dir(extension_id) / "extension.json"
+
+ @contextmanager
+ def _lock(self, extension_id: str) -> Iterator[None]:
+ locks = self.store_root / ".locks"
+ locks.mkdir(parents=True, exist_ok=True)
+ with (locks / f"{extension_id}.lock").open("a+") as handle:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ try:
+ yield
+ finally:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+
+ def _read_record(self, extension_id: str) -> dict[str, Any]:
+ try:
+ return json.loads(self._record_path(extension_id).read_text(encoding="utf-8"))
+ except FileNotFoundError as exc:
+ raise ValueError(f"extension does not exist: {extension_id}") from exc
+
+ def _write_record(self, extension_id: str, record: dict[str, Any]) -> None:
+ path = self._record_path(extension_id)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
+ with tempfile.NamedTemporaryFile(
+ mode="w", encoding="utf-8", dir=path.parent, prefix=".extension-", delete=False
+ ) as handle:
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ temporary = Path(handle.name)
+ temporary.replace(path)
+ directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(directory_fd)
+ finally:
+ os.close(directory_fd)
+
+ def get(self, extension_id: str) -> dict[str, Any]:
+ return self._read_record(extension_id)
+
+ def _deepseek_candidate(self, facts: dict[str, Any], raw_code: Path) -> tuple[dict[str, Any], dict[str, Any]]:
+ return generate_semantic_candidate(
+ execution_facts=facts, raw_code=raw_code, environ=self.environment
+ )
+
+ @staticmethod
+ def _state(record: dict[str, Any], *allowed: str) -> None:
+ if record.get("status") not in allowed:
+ raise LifecycleStateError(
+ f"action requires status in {', '.join(allowed)}, got {record.get('status')}"
+ )
+
+ @staticmethod
+ def _response(
+ record: dict[str, Any], *, token: str, action: str, state: str, run_id: str | None = None
+ ) -> dict[str, Any]:
+ result = {
+ "extension_id": record["extension_id"],
+ "operation_token": token,
+ "action": action,
+ "state": state,
+ "status": record["status"],
+ }
+ if run_id is not None:
+ result["run_id"] = run_id
+ return result
+
+ def _existing_operation(
+ self, record: dict[str, Any], *, token: str, action: str
+ ) -> dict[str, Any] | None:
+ existing = record.setdefault("operations", {}).get(token)
+ if existing is None:
+ return None
+ if existing.get("action") != action:
+ raise ValueError("operation_token is already bound to another action")
+ return deepcopy(existing["response"])
+
+ def _short_operation(
+ self,
+ extension_id: str,
+ *,
+ token: str,
+ action: str,
+ mutate: Callable[[dict[str, Any]], None],
+ ) -> dict[str, Any]:
+ if not token.strip():
+ raise ValueError("operation_token is required")
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ previous = self._existing_operation(record, token=token, action=action)
+ if previous is not None:
+ return previous
+ mutate(record)
+ response = self._response(record, token=token, action=action, state="completed")
+ record["operations"][token] = {
+ "action": action,
+ "state": "completed",
+ "response": response,
+ }
+ self._write_record(extension_id, record)
+ return deepcopy(response)
+
+ def _begin_long_operation(
+ self,
+ extension_id: str,
+ *,
+ token: str,
+ action: str,
+ running_status: str,
+ allowed: tuple[str, ...],
+ run_id: str,
+ ) -> tuple[dict[str, Any] | None, dict[str, Any]]:
+ if not token.strip():
+ raise ValueError("operation_token is required")
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ previous = self._existing_operation(record, token=token, action=action)
+ if previous is not None:
+ return None, previous
+ self._state(record, *allowed)
+ record["status"] = running_status
+ record["active_process"] = None
+ response = self._response(
+ record, token=token, action=action, state="running", run_id=run_id
+ )
+ record["operations"][token] = {
+ "action": action,
+ "state": "running",
+ "run_id": run_id,
+ "response": response,
+ }
+ self._write_record(extension_id, record)
+ return record, deepcopy(response)
+
+ def _mark_process(
+ self,
+ extension_id: str,
+ *,
+ token: str,
+ phase: str,
+ identity: ProcessIdentity,
+ ) -> None:
+ stop_after_recording = False
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ operation = record.get("operations", {}).get(token)
+ if operation is None or operation.get("state") != "running":
+ return
+ record["active_process"] = {
+ "operation_token": token,
+ "phase": phase,
+ "identity": dict(identity),
+ }
+ stop_after_recording = operation.get("stop_requested") is True
+ self._write_record(extension_id, record)
+ if not stop_after_recording:
+ return
+ stopped = stop_process_group(identity, grace_seconds=1.0)
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ active = record.get("active_process")
+ if isinstance(active, dict) and active.get("operation_token") == token:
+ active["stop_result"] = stopped
+ self._write_record(extension_id, record)
+
+ def _finish_long_operation(
+ self,
+ extension_id: str,
+ *,
+ token: str,
+ action: str,
+ run_id: str,
+ mutate: Callable[[dict[str, Any]], None],
+ ) -> dict[str, Any]:
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ operation = record.get("operations", {}).get(token)
+ if operation is None or operation.get("action") != action:
+ raise LifecycleStateError("operation record was lost before completion")
+ mutate(record)
+ active = record.get("active_process")
+ if isinstance(active, dict) and active.get("operation_token") == token:
+ record["active_process"] = None
+ response = self._response(
+ record, token=token, action=action, state="completed", run_id=run_id
+ )
+ operation.update({"state": "completed", "response": response})
+ self._write_record(extension_id, record)
+ return deepcopy(response)
+
+ @staticmethod
+ def _error(phase: str, error: BaseException | str) -> dict[str, Any]:
+ result: dict[str, Any] = {"phase": phase, "message": str(error)}
+ metadata = getattr(error, "metadata", None)
+ if isinstance(metadata, dict) and metadata:
+ result["metadata"] = deepcopy(metadata)
+ return result
+
+ def _build_candidate(
+ self,
+ record: dict[str, Any],
+ evidence: dict[str, Any],
+ *,
+ facts: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ root = self.extension_dir(record["extension_id"])
+ facts = facts or build_execution_facts(evidence)
+ candidate, metadata = self.semantic_generator(facts, root / "raw_code")
+ return {
+ "baseline": evidence,
+ "execution_facts": facts,
+ "semantic_candidate": candidate,
+ "semantic_metadata": metadata,
+ "semantic_repair_history": [],
+ "confirmation": create_confirmation(execution_facts=facts, candidate=candidate),
+ "runtime_binding_contract": build_runtime_binding_contract(
+ facts, candidate, root / "raw_code"
+ ),
+ "validation_runs": [],
+ "reviewed_validation_run_id": None,
+ "published_validation_run_id": None,
+ "error": None,
+ "status": "confirmation_pending",
+ }
+
+ def _run_baseline_operation(
+ self,
+ extension_id: str,
+ *,
+ token: str,
+ action: str,
+ allowed: tuple[str, ...],
+ reserved: bool = False,
+ run_id: str | None = None,
+ ) -> dict[str, Any]:
+ run_id = run_id or f"baseline_{uuid4().hex}"
+ if reserved:
+ record = self.get(extension_id)
+ else:
+ record, existing = self._begin_long_operation(
+ extension_id,
+ token=token,
+ action=action,
+ running_status="baseline_running",
+ allowed=allowed,
+ run_id=run_id,
+ )
+ if record is None:
+ return existing
+ root = self.extension_dir(extension_id)
+ evidence: dict[str, Any] | None = None
+ facts: dict[str, Any] | None = None
+ build: dict[str, Any] | None = None
+ failure: tuple[str, BaseException | str] | None = None
+ try:
+ evidence = run_raw_baseline(
+ command=record["original_command"],
+ raw_code=root / "raw_code",
+ raw_data=root / "raw_test_data",
+ run_dir=root / "baseline" / run_id,
+ env=self.environment,
+ on_process_start=lambda identity: self._mark_process(
+ extension_id, token=token, phase="baseline", identity=identity
+ ),
+ )
+ if not evidence["success"]:
+ failure = ("baseline", f"original command returned {evidence['returncode']}")
+ else:
+ facts = build_execution_facts(evidence)
+ build = self._build_candidate(record, evidence, facts=facts)
+ except BaseException as exc:
+ failure = ("semantic_candidate" if evidence is not None else "baseline", exc)
+
+ def finish(current: dict[str, Any]) -> None:
+ if build is not None:
+ current.update(build)
+ return
+ if evidence is not None:
+ current["baseline"] = evidence
+ if facts is not None:
+ current["execution_facts"] = facts
+ phase, detail = failure or ("baseline", "unknown build failure")
+ current["error"] = self._error(phase, detail)
+ current["status"] = "baseline_failed" if phase == "baseline" else "build_failed"
+
+ return self._finish_long_operation(
+ extension_id, token=token, action=action, run_id=run_id, mutate=finish
+ )
+
+ def upload(
+ self,
+ *,
+ extension_id: str,
+ raw_code: Path,
+ raw_data: Path,
+ original_command: str,
+ operation_token: str,
+ ) -> dict[str, Any]:
+ raw_code, raw_data = Path(raw_code), Path(raw_data)
+ if not isinstance(operation_token, str) or not operation_token.strip():
+ raise ValueError("operation_token is required")
+ if not raw_code.is_dir() or not raw_data.is_dir():
+ raise ValueError("raw_code and raw_data must be directories")
+ if not original_command.strip():
+ raise ValueError("original_command is required")
+ root = self.extension_dir(extension_id)
+ run_id = f"baseline_{uuid4().hex}"
+ with self._lock(extension_id):
+ if self._record_path(extension_id).exists():
+ record = self._read_record(extension_id)
+ previous = self._existing_operation(
+ record, token=operation_token, action="upload"
+ )
+ if previous is None:
+ raise LifecycleStateError("extension already exists")
+ return previous
+ root.mkdir(parents=True, exist_ok=False)
+ _copy_tree(raw_code, root / "raw_code")
+ _copy_tree(raw_data, root / "raw_test_data")
+ self._write_record(
+ extension_id,
+ {
+ "schema_version": "lightweight-extension-service/v1",
+ "extension_id": extension_id,
+ "original_command": original_command,
+ "status": "baseline_running",
+ "operations": {
+ operation_token: {
+ "action": "upload",
+ "state": "running",
+ "run_id": run_id,
+ "response": {
+ "extension_id": extension_id,
+ "operation_token": operation_token,
+ "action": "upload",
+ "state": "running",
+ "status": "baseline_running",
+ "run_id": run_id,
+ },
+ }
+ },
+ "active_process": None,
+ "error": None,
+ },
+ )
+ return self._run_baseline_operation(
+ extension_id,
+ token=operation_token,
+ action="upload",
+ allowed=("uploaded",),
+ reserved=True,
+ run_id=run_id,
+ )
+
+ def rebuild(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ return self._run_baseline_operation(
+ extension_id,
+ token=operation_token,
+ action="rebuild",
+ allowed=("uploaded", "rebuild_required", "baseline_failed", "build_failed", "semantic_repair_failed", "validation_failed"),
+ )
+
+ def retry_semantic(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ if self.get(extension_id).get("status") == "confirmation_pending":
+ return self._short_operation(
+ extension_id,
+ token=operation_token,
+ action="retry_semantic",
+ mutate=lambda record: self._state(record, "confirmation_pending"),
+ )
+ run_id = f"semantic_{uuid4().hex}"
+ record, existing = self._begin_long_operation(
+ extension_id,
+ token=operation_token,
+ action="retry_semantic",
+ running_status="semantic_candidate_running",
+ allowed=("build_failed",),
+ run_id=run_id,
+ )
+ if record is None:
+ return existing
+ evidence = record.get("baseline")
+ facts = record.get("execution_facts")
+ build: dict[str, Any] | None = None
+ failure: BaseException | None = None
+ if isinstance(evidence, dict) and not isinstance(facts, dict):
+ try:
+ facts = build_execution_facts(evidence)
+ except BaseException as exc:
+ failure = exc
+ if not isinstance(evidence, dict) or not isinstance(facts, dict):
+ failure = LifecycleStateError(
+ "semantic retry requires saved baseline evidence and execution facts"
+ )
+ elif failure is None:
+ try:
+ build = self._build_candidate(record, evidence, facts=facts)
+ except BaseException as exc:
+ failure = exc
+
+ def finish(current: dict[str, Any]) -> None:
+ if build is not None:
+ current.update(build)
+ return
+ if isinstance(facts, dict):
+ current["execution_facts"] = facts
+ current["error"] = self._error(
+ "semantic_candidate", failure or "unknown semantic generation failure"
+ )
+ current["status"] = "build_failed"
+
+ return self._finish_long_operation(
+ extension_id,
+ token=operation_token,
+ action="retry_semantic",
+ run_id=run_id,
+ mutate=finish,
+ )
+
+ def revise(
+ self,
+ extension_id: str,
+ *,
+ feedback: str,
+ revision_target: str,
+ operation_token: str,
+ ) -> dict[str, Any]:
+ def revise_record(record: dict[str, Any]) -> None:
+ self._state(record, "confirmation_pending", "semantic_repair_failed")
+ decision = apply_confirmation_action(
+ confirmation=record["confirmation"],
+ action="revise",
+ execution_facts=record["execution_facts"],
+ candidate=record["semantic_candidate"],
+ feedback=feedback,
+ revision_target=revision_target,
+ )
+ if decision["status"] == "fact_dispute":
+ record["confirmation"] = decision
+ record["status"] = "rebuild_required"
+ return
+ try:
+ candidate, history = repair_semantic_candidate(
+ execution_facts=record["execution_facts"],
+ candidate=record["semantic_candidate"],
+ user_feedback=feedback,
+ patch_generator=self.patch_generator,
+ raw_code=self.extension_dir(extension_id) / "raw_code",
+ environ=self.environment,
+ )
+ except BaseException as exc:
+ record["status"] = "semantic_repair_failed"
+ record["error"] = self._error("semantic_repair", exc)
+ return
+ if not history or history[-1]["status"] != "success":
+ record["semantic_repair_history"] = history
+ record["status"] = "semantic_repair_failed"
+ record["error"] = self._error("semantic_repair", "semantic repair was rejected")
+ return
+ record["semantic_candidate"] = candidate
+ record["semantic_repair_history"] = history
+ record["confirmation"] = create_confirmation(
+ execution_facts=record["execution_facts"], candidate=candidate, repair_history=history
+ )
+ record["runtime_binding_contract"] = build_runtime_binding_contract(
+ record["execution_facts"], candidate, self.extension_dir(extension_id) / "raw_code"
+ )
+ record["error"] = None
+ record["status"] = "confirmation_pending"
+
+ return self._short_operation(
+ extension_id, token=operation_token, action="revise", mutate=revise_record
+ )
+
+ def confirm(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ def confirm_record(record: dict[str, Any]) -> None:
+ self._state(record, "confirmation_pending")
+ confirmation = apply_confirmation_action(
+ confirmation=record["confirmation"], action="confirm",
+ execution_facts=record["execution_facts"], candidate=record["semantic_candidate"],
+ )
+ if not is_candidate_accepted(
+ confirmation=confirmation,
+ execution_facts=record["execution_facts"], candidate=record["semantic_candidate"],
+ ):
+ raise LifecycleStateError("candidate confirmation is stale")
+ record["confirmation"] = confirmation
+ record["status"] = "confirmed"
+
+ return self._short_operation(
+ extension_id, token=operation_token, action="confirm", mutate=confirm_record
+ )
+
+ def _default_inputs(self, record: dict[str, Any]) -> dict[str, Path]:
+ data = self.extension_dir(record["extension_id"]) / "raw_test_data"
+ return {
+ item["path"]: data / item["path"]
+ for item in record["runtime_binding_contract"].get("inputs", [])
+ if item.get("replaceable")
+ }
+
+ @staticmethod
+ def _job_path(path: Path, roots: tuple[Path, Path]) -> Path:
+ candidate = Path(os.path.abspath(Path(path).expanduser())).resolve()
+ resolved_roots = tuple(root.resolve() for root in roots)
+ if not candidate.is_file() or not any(
+ candidate.is_relative_to(root) for root in resolved_roots
+ ):
+ raise ValueError("runtime input must be a file inside the selected job")
+ return candidate
+
+ def _execute(
+ self,
+ record: dict[str, Any],
+ *,
+ run_id: str,
+ run_dir: Path,
+ input_overrides: dict[str, Path],
+ parameter_overrides: dict[str, Any] | None = None,
+ explicit_prefix: str | None = None,
+ operation_token: str,
+ phase: str,
+ ) -> dict[str, Any]:
+ root = self.extension_dir(record["extension_id"])
+ resolved = resolve_runtime_bindings(
+ record["runtime_binding_contract"], input_overrides, parameter_overrides or {},
+ explicit_prefix=explicit_prefix,
+ )
+ if resolved["status"] != "resolved":
+ return {
+ "run_id": run_id, "success": False, "contract_passed": False,
+ "binding": resolved, "projection": {"files": [], "stdout": {"present": False}},
+ }
+ rendered = render_runtime_package(
+ record["runtime_binding_contract"], root / "raw_code", resolved, run_dir
+ )
+ result = replay_runtime_binding(
+ rendered,
+ env=self.environment,
+ on_process_start=lambda identity: self._mark_process(
+ record["extension_id"], token=operation_token, phase=phase, identity=identity
+ ),
+ )
+ result["run_id"] = run_id
+ result["binding"] = resolved
+ result["projection"] = self._project_outputs(run_dir, result)
+ return result
+
+ @staticmethod
+ def _project_outputs(run_dir: Path, result: dict[str, Any]) -> dict[str, Any]:
+ output_root = run_dir / "outputs"
+ output_root.mkdir(exist_ok=True)
+ files: list[dict[str, Any]] = []
+ for item in result.get("outputs", []):
+ runtime_path = item["runtime_path"]
+ source = run_dir / "work" / runtime_path
+ destination = output_root / runtime_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, destination)
+ files.append({**item, "path": runtime_path, "absolute_path": str(destination)})
+ stdout_path = Path(result.get("logs", {}).get("stdout", ""))
+ stdout = {"present": stdout_path.is_file() and stdout_path.stat().st_size > 0}
+ if stdout["present"]:
+ stdout.update({"path": str(stdout_path), "text": stdout_path.read_text(encoding="utf-8", errors="replace")})
+ return {"files": files, "stdout": stdout}
+
+ def validate(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ run_id = f"validation_{uuid4().hex}"
+ record, existing = self._begin_long_operation(
+ extension_id, token=operation_token, action="validate", running_status="validation_running",
+ allowed=("confirmed", "validation_failed"), run_id=run_id,
+ )
+ if record is None:
+ return existing
+ result: dict[str, Any]
+ try:
+ result = self._execute(
+ record, run_id=run_id, run_dir=self.extension_dir(extension_id) / "runs" / run_id,
+ input_overrides=self._default_inputs(record), operation_token=operation_token, phase="validation",
+ )
+ except BaseException as exc:
+ result = {"run_id": run_id, "success": False, "contract_passed": False, "error": self._error("validation", exc), "projection": {"files": [], "stdout": {"present": False}}}
+
+ def finish(current: dict[str, Any]) -> None:
+ current.setdefault("validation_runs", []).append(result)
+ current["status"] = "result_review_pending" if result.get("contract_passed") else "validation_failed"
+ current["error"] = None if result.get("contract_passed") else result.get("error")
+
+ return self._finish_long_operation(
+ extension_id, token=operation_token, action="validate", run_id=run_id, mutate=finish
+ )
+
+ def review_result(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ def review(record: dict[str, Any]) -> None:
+ self._state(record, "result_review_pending")
+ run = record["validation_runs"][-1]
+ if not run.get("contract_passed"):
+ raise LifecycleStateError("only a successful validation run can be reviewed")
+ record["reviewed_validation_run_id"] = run["run_id"]
+ record["status"] = "reviewed"
+
+ return self._short_operation(extension_id, token=operation_token, action="review_result", mutate=review)
+
+ def publish(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ def publish_record(record: dict[str, Any]) -> None:
+ self._state(record, "reviewed")
+ reviewed = record.get("reviewed_validation_run_id")
+ run = next((item for item in record["validation_runs"] if item["run_id"] == reviewed), None)
+ if run is None or not run.get("contract_passed"):
+ raise LifecycleStateError("publication requires the reviewed successful validation run")
+ record["published_validation_run_id"] = reviewed
+ record["status"] = "published"
+
+ return self._short_operation(extension_id, token=operation_token, action="publish", mutate=publish_record)
+
+ def help(self, extension_id: str) -> dict[str, Any]:
+ record = self.get(extension_id)
+ return {
+ "extension_id": extension_id, "status": record["status"],
+ "function_summary": record.get("semantic_candidate", {}).get("function_summary"),
+ "original_command": record["original_command"],
+ "inputs": deepcopy(record.get("runtime_binding_contract", {}).get("inputs", [])),
+ "parameters": deepcopy(record.get("runtime_binding_contract", {}).get("parameters", [])),
+ "outputs": deepcopy(record.get("runtime_binding_contract", {}).get("outputs", {})),
+ }
+
+ def run(
+ self,
+ extension_id: str,
+ *,
+ input_overrides: dict[str, Path],
+ run_job_id: str | None = None,
+ input_root: Path | None = None,
+ workspace_root: Path | None = None,
+ parameter_overrides: dict[str, Any] | None = None,
+ explicit_prefix: str | None = None,
+ operation_token: str,
+ ) -> dict[str, Any]:
+ if (
+ not run_job_id
+ or run_job_id in {".", ".."}
+ or "/" in run_job_id
+ or "\\" in run_job_id
+ or input_root is None
+ or workspace_root is None
+ ):
+ raise ValueError("run_job_id, input_root, and workspace_root are required")
+ job_input = Path(os.path.abspath(Path(input_root) / run_job_id))
+ job_workspace = Path(os.path.abspath(Path(workspace_root) / run_job_id))
+ if not job_input.is_dir() or not job_workspace.is_dir():
+ raise ValueError("selected job input and workspace directories must exist")
+ checked_inputs = {
+ key: self._job_path(Path(value), (job_input, job_workspace))
+ for key, value in input_overrides.items()
+ }
+ run_id = f"runtime_{uuid4().hex}"
+ record, existing = self._begin_long_operation(
+ extension_id, token=operation_token, action="run", running_status="runtime_running",
+ allowed=("published",), run_id=run_id,
+ )
+ if record is None:
+ return existing
+ result: dict[str, Any]
+ try:
+ result = self._execute(
+ record, run_id=run_id,
+ run_dir=job_workspace / "extension_runs" / extension_id / run_id,
+ input_overrides=checked_inputs, parameter_overrides=parameter_overrides,
+ explicit_prefix=explicit_prefix, operation_token=operation_token, phase="runtime",
+ )
+ except BaseException as exc:
+ result = {"run_id": run_id, "success": False, "contract_passed": False, "error": self._error("runtime", exc), "projection": {"files": [], "stdout": {"present": False}}}
+
+ def finish(current: dict[str, Any]) -> None:
+ current.setdefault("runtime_runs", []).append(result)
+ current["status"] = "published"
+
+ return self._finish_long_operation(
+ extension_id, token=operation_token, action="run", run_id=run_id, mutate=finish
+ )
+
+ def stop(self, extension_id: str, *, operation_token: str) -> dict[str, Any]:
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ active = record.get("active_process")
+ operation = record.get("operations", {}).get(operation_token)
+ if not isinstance(operation, dict) or operation.get("state") != "running":
+ return {"extension_id": extension_id, "operation_token": operation_token, "stopped": False}
+ identity = (
+ active.get("identity")
+ if isinstance(active, dict) and active.get("operation_token") == operation_token
+ else None
+ )
+ operation["stop_requested"] = True
+ self._write_record(extension_id, record)
+ stopped = (
+ stop_process_group(identity, grace_seconds=1.0)
+ if isinstance(identity, dict)
+ else False
+ )
+ with self._lock(extension_id):
+ record = self._read_record(extension_id)
+ active = record.get("active_process")
+ if isinstance(active, dict) and active.get("operation_token") == operation_token:
+ active["stop_result"] = stopped
+ self._write_record(extension_id, record)
+ elif not stopped:
+ operation = record.get("operations", {}).get(operation_token, {})
+ runs = [record.get("baseline", {})]
+ runs.extend(record.get("validation_runs", []))
+ runs.extend(record.get("runtime_runs", []))
+ stopped = bool(
+ operation.get("state") == "completed"
+ and any(
+ isinstance(item, dict)
+ and item.get("returncode", 0) < 0
+ and (
+ operation.get("action") in {"upload", "rebuild"}
+ or item.get("run_id") == operation.get("run_id")
+ )
+ for item in runs
+ )
+ )
+ return {"extension_id": extension_id, "operation_token": operation_token, "stopped": stopped}
diff --git a/gfa_core/gene_family_scope.py b/gfa_core/gene_family_scope.py
index e314c7a01..467988388 100755
--- a/gfa_core/gene_family_scope.py
+++ b/gfa_core/gene_family_scope.py
@@ -12,6 +12,7 @@ from dataclasses import asdict, dataclass, field
from pathlib import Path
import hashlib
import json
+import os
import re
from typing import Any, Iterable
diff --git a/gfa_core/gene_family_stage_context.py b/gfa_core/gene_family_stage_context.py
index f66af3c8f..5219e2ca5 100755
--- a/gfa_core/gene_family_stage_context.py
+++ b/gfa_core/gene_family_stage_context.py
@@ -106,7 +106,7 @@ def infer_analysis_route(stage: str, scope_data: dict[str, Any] | None = None) -
if scope_mode == "target_only" and len(targets) > 1:
return "multi_target"
if refs and scope_mode in {"target_plus_reference", "target_plus_reference_plus_comparative"}:
- return "subfamily_prediction" if stage in {"phylogeny"} else "multi_species"
+ return "multi_species"
if comps or len(targets) > 1:
return "multi_species"
return scope_mode or "custom"
diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py
index 709acae89..5ba61a18b 100644
--- a/gfa_core/mas/agents/router_runtime.py
+++ b/gfa_core/mas/agents/router_runtime.py
@@ -40,6 +40,7 @@ class RouterOutcome(BaseModel):
interaction_kind: InteractionKind = "independent"
proposal: dict[str, str] | None = None
input_paths: list[str] = Field(default_factory=list)
+ ui_action: Literal["open_config_dialog"] | None = None
RouteGoal = Literal[
@@ -635,6 +636,15 @@ class RouterRuntime:
job_id: str,
goal_ledger: GoalLedger | None = None,
) -> RouterOutcome:
+ ui_action = self._config_dialog_ui_action(query)
+ if ui_action is not None:
+ return RouterOutcome(
+ status="ok",
+ action="reply",
+ message="已打开当前 job 的 config.xlsx 核对表。",
+ ui_action=ui_action,
+ )
+
pending_proposal = self._pending_analysis_proposal(goal_ledger)
confirmed_proposal = (
pending_proposal
@@ -1322,6 +1332,18 @@ class RouterRuntime:
tool_trace=tool_trace,
)
+ @staticmethod
+ def _config_dialog_ui_action(query: str) -> str | None:
+ compact = re.sub(r"[\s,,。!!??]+", "", str(query or "").lower())
+ polite = r"(?:请|请帮我|帮我|麻烦(?:你)?|可以|能否)?(?:给我)?"
+ target = r"(?:当前job的?)?(?:config(?:\.xlsx)?|配置(?:表|文件)?)"
+ action = r"(?:重新)?(?:打开|调出|弹出)(?:一下)?"
+ patterns = (
+ rf"^{polite}{action}{target}(?:弹窗)?$",
+ rf"^{polite}(?:把)?{target}(?:给我)?{action}$",
+ )
+ return "open_config_dialog" if any(re.fullmatch(pattern, compact) for pattern in patterns) else None
+
@staticmethod
def _is_explicit_job_file_read(query: str) -> bool:
text = str(query or "").strip()
diff --git a/gfa_core/mas/artifact_resolver.py b/gfa_core/mas/artifact_resolver.py
index 3c0130417..4b10db6ce 100755
--- a/gfa_core/mas/artifact_resolver.py
+++ b/gfa_core/mas/artifact_resolver.py
@@ -436,6 +436,7 @@ class ArtifactResolver:
"protein_fasta": {"fasta"},
"table": {"id_list", "expression_matrix"},
"manifest": {"metadata"},
+ "tree": {"newick"},
}
return actual in aliases.get(required, set())
diff --git a/gfa_core/mas/persistent_conversation.py b/gfa_core/mas/persistent_conversation.py
index a331b22d7..bca667594 100644
--- a/gfa_core/mas/persistent_conversation.py
+++ b/gfa_core/mas/persistent_conversation.py
@@ -97,7 +97,7 @@ class PersistentConversationStore:
return bound or requested
def rebind_job(self, *, session_id: str, job_id: str) -> str:
- """Explicitly switch a conversation to one job and drop old job semantics."""
+ """Bind one conversation to one job without mixing histories."""
conversation_id = _conversation_id(session_id)
requested = str(job_id or "").strip()
if not requested:
@@ -107,13 +107,36 @@ class PersistentConversationStore:
raise ValueError("conversation is missing")
previous_job = str(conversation.job_id or "")
current = self.repository.get_goal_ledger(conversation_id)
+ ledger_job = str(
+ (current.user_terms if current is not None else {}).get("active_job_id")
+ or ""
+ ).strip()
+ bound_job = previous_job or ledger_job
+ if bound_job == requested:
+ return conversation_id
+ if bound_job:
+ replacement_id = str(uuid4())
+ self.repository.create_conversation(
+ conversation_id=replacement_id,
+ principal_id=conversation.principal_id,
+ job_id=requested,
+ )
+ self.repository.put_goal_ledger(
+ GoalLedger(
+ conversation_id=replacement_id,
+ revision=0,
+ user_terms={"active_job_id": requested},
+ ),
+ expected_revision=None,
+ )
+ return replacement_id
self.repository.update_conversation(
conversation_id=conversation_id,
principal_id=conversation.principal_id,
job_id=requested,
)
if current is None:
- return requested
+ return conversation_id
replacement = GoalLedger(
conversation_id=conversation_id,
revision=current.revision + 1,
@@ -131,7 +154,7 @@ class PersistentConversationStore:
job_id=previous_job,
)
raise
- return requested
+ return conversation_id
def begin_turn(
self,
diff --git a/gfa_core/mas/tools/stage_tools.py b/gfa_core/mas/tools/stage_tools.py
index e9c27f2da..cc76e2f87 100755
--- a/gfa_core/mas/tools/stage_tools.py
+++ b/gfa_core/mas/tools/stage_tools.py
@@ -304,6 +304,7 @@ class StageToolExecutor:
blast_identity=(extra_params or {}).get("blastIdentity", (extra_params or {}).get("blast_identity", 30.0)),
phy_mafft=(extra_params or {}).get("mafftParams", f"--auto --thread {cpu}"),
phy_iqtree=(extra_params or {}).get("iqtreeParams", f"-m MFP -bb 1000 -nt {cpu}"),
+ tree_file=(extra_params or {}).get("treeFile", "") or (extra_params or {}).get("tree_file", ""),
reference_subfamily_file=(extra_params or {}).get("referenceSubfamilyFile", ""),
reference_target_prefix=(extra_params or {}).get("referenceTargetPrefix", ""),
reference_prefix=(extra_params or {}).get("referencePrefix", "At"),
diff --git a/start_celery_9000.sh b/start_celery_9000.sh
index d5dd2a070..50a257db2 100755
--- a/start_celery_9000.sh
+++ b/start_celery_9000.sh
@@ -47,6 +47,10 @@ CONDA_ENV="$CONDA_BASE/envs/$DEFAULT_ENV"
export PATH="$CONDA_ENV/bin:$PATH"
export BBAV5_PROJECT_ROOT="${BBAV5_PROJECT_ROOT:-$PROJECT_ROOT}"
export PLANTAI_PROJECT_ROOT="${PLANTAI_PROJECT_ROOT:-$PROJECT_ROOT}"
+export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$PROJECT_ROOT/.env}"
+export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$PROJECT_ROOT/.runtime/lightweight-extension/store}"
+export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$PROJECT_ROOT/input}"
+export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$PROJECT_ROOT/workspace}"
export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}"
export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}"
WORKER_NAME_PREFIX="${BBAV5_WORKER_NAME_PREFIX:-${BBAV5_QUEUE_PREFIX//[^A-Za-z0-9_]/_}}"
diff --git a/start_plantai_9000.sh b/start_plantai_9000.sh
index 1abf62ddb..e907169fa 100755
--- a/start_plantai_9000.sh
+++ b/start_plantai_9000.sh
@@ -15,6 +15,10 @@ export PATH="$CONDA_ENV/bin:$PATH"
# the caller's shell; GWAS/other sandboxes have dedicated restart scripts.
export BBAV5_PROJECT_ROOT="$SCRIPT_DIR"
export PLANTAI_PROJECT_ROOT="$SCRIPT_DIR"
+export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$SCRIPT_DIR/.env}"
+export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$SCRIPT_DIR/.runtime/lightweight-extension/store}"
+export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$SCRIPT_DIR/input}"
+export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$SCRIPT_DIR/workspace}"
export BBAV5_QUEUE_PREFIX="plantai_dev_9000"
export BBAV5_REDIS_URL="redis://localhost:6379/10"
export PLANTAI_WEB_PORT="9000"
diff --git a/tasks.py b/tasks.py
index d443165fc..6afc4df46 100755
--- a/tasks.py
+++ b/tasks.py
@@ -13,6 +13,19 @@ sys.path.insert(0, str(PROJECT_ROOT))
from celery_app import app
+@app.task(
+ bind=True,
+ name="tasks.execute_lightweight_extension_operation",
+ time_limit=None,
+ soft_time_limit=None,
+)
+def execute_lightweight_extension_operation(self, payload: dict) -> dict:
+ """Run one lightweight-extension operation without a Celery deadline."""
+ from gfa_core.extension_stage_lightweight.api import execute_queued_operation
+
+ return execute_queued_operation(payload)
+
+
def normalize_stage_params(stage: str, params: dict | None) -> tuple[dict, dict]:
"""Clamp per-stage CPU at the worker boundary and return resource metadata."""
params = dict(params or {})
@@ -27,16 +40,19 @@ def normalize_stage_params(stage: str, params: dict | None) -> tuple[dict, dict]
explicit_cpu = requested_cpu not in {None, "", 0, "0"}
project_limit = rnaseq_project_cpu_limit(requested_cpu if explicit_cpu else None)
+ explicit_threads = params.get("threads_per_sample") or params.get("threadsPerSample")
plan = recommend_rnaseq_parallelism(
total_cpu=project_limit,
sample_parallel_jobs=params.get("sample_parallel_jobs") or params.get("sampleParallelJobs"),
- threads_per_sample=params.get("threads_per_sample") or params.get("threadsPerSample"),
- sample_count=params.get("sample_count") or params.get("sampleCount"),
stage=stage,
)
- effective_cpu = clamp_stage_cpu(stage, plan["total_cpu"])
+ effective_cpu = clamp_stage_cpu(stage, project_limit)
params["sample_parallel_jobs"] = plan["sample_parallel_jobs"]
- params["threads_per_sample"] = plan["threads_per_sample"]
+ if explicit_threads:
+ params["threads_per_sample"] = max(1, int(explicit_threads))
+ else:
+ params.pop("threads_per_sample", None)
+ params.pop("threadsPerSample", None)
params["rnaseq_project_max_cpu"] = plan["project_cpu_limit"]
params["resource_decision"] = {
"stage": stage,
@@ -44,8 +60,11 @@ def normalize_stage_params(stage: str, params: dict | None) -> tuple[dict, dict]
"effective_cpu": effective_cpu,
"project_cpu_limit": plan["project_cpu_limit"],
"sample_parallel_jobs": plan["sample_parallel_jobs"],
- "threads_per_sample": plan["threads_per_sample"],
- "reason": f"{stage} project CPU budget split across sample-level parallel jobs",
+ "threads_per_sample": max(1, int(explicit_threads)) if explicit_threads else None,
+ "reason": (
+ f"{stage} preserves the selected total CPU budget; per-sample threads "
+ "are derived after the real sample count is known"
+ ),
}
except Exception as exc:
requested_cpu = params.get("requested_cpu", params.get("cpu"))
diff --git a/tests/unit/test_autofill_entrypoints.py b/tests/unit/test_autofill_entrypoints.py
index d28f699c4..c0b196e51 100755
--- a/tests/unit/test_autofill_entrypoints.py
+++ b/tests/unit/test_autofill_entrypoints.py
@@ -7,6 +7,28 @@ if str(ROOT) not in sys.path:
from apps.cli.run_config_autofill import build_rows, merge_rows
from apps.cli.autofill_common import scan_files
+from gfa_core.mas.agents.router_runtime import RouterRuntime
+
+
+def test_direct_config_open_commands_map_to_config_dialog_action():
+ for query in (
+ "打开 config",
+ "打开 config.xlsx",
+ "重新打开配置表",
+ "帮我打开当前 job 的配置",
+ ):
+ assert RouterRuntime._config_dialog_ui_action(query) == "open_config_dialog"
+
+
+def test_config_discussion_does_not_map_to_config_dialog_action():
+ for query in (
+ "读取 config.xlsx 中有哪些物种",
+ "检查 config 是否正确",
+ "我已经打开 config,下一步做什么",
+ "config 为什么打不开",
+ "config 已确认,执行数据准备",
+ ):
+ assert RouterRuntime._config_dialog_ui_action(query) is None
def test_at_spl_pep_upload_is_detected_as_seed_reference(tmp_path):
@@ -98,6 +120,16 @@ def test_backend_open_endpoints_prefer_draft_then_confirmed_file():
assert 'input_dir / "samples.yaml"' in main_py
+def test_chat_config_dialog_action_is_transmitted_and_opens_current_job_config():
+ main_py = (ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
+ html = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
+
+ assert '"ui_action": outcome.get("ui_action")' in main_py
+ assert "data.intent.ui_action === 'open_config_dialog'" in html
+ assert "await openAutofillDialog('config'" in html
+ assert "await openExistingAutofillDraft('config')" in html
+
+
def test_samples_confirm_no_longer_blocks_on_pending_de_roles_and_closes_dialog():
html = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
confirm_start = html.index("const confirmAutofillDraft")
@@ -110,4 +142,3 @@ def test_samples_confirm_no_longer_blocks_on_pending_de_roles_and_closes_dialog(
assert "请在样本表中至少标记一个比较组" not in confirm_block
assert "autofillDialogVisible.value = false" in confirm_block
-
diff --git a/tests/unit/test_extension_stage_lightweight_api.py b/tests/unit/test_extension_stage_lightweight_api.py
new file mode 100644
index 000000000..5ab656ad7
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_api.py
@@ -0,0 +1,930 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import threading
+import asyncio
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+
+class _FakeService:
+ def __init__(self, record: dict | None = None) -> None:
+ self.record = record or {
+ "extension_id": "count_fasta",
+ "status": "validation_running",
+ "operations": {
+ "validate-1": {
+ "action": "validate",
+ "state": "running",
+ "response": {
+ "extension_id": "count_fasta",
+ "operation_token": "validate-1",
+ "action": "validate",
+ "state": "running",
+ },
+ }
+ },
+ "validation_runs": [],
+ "runtime_runs": [],
+ }
+ self.stops: list[tuple[str, str]] = []
+
+ def get(self, extension_id: str) -> dict:
+ assert extension_id == self.record["extension_id"]
+ return self.record
+
+ def stop(self, extension_id: str, *, operation_token: str) -> dict:
+ self.stops.append((extension_id, operation_token))
+ return {
+ "extension_id": extension_id,
+ "operation_token": operation_token,
+ "stopped": True,
+ }
+
+ def help(self, extension_id: str) -> dict:
+ return {"extension_id": extension_id, "function_summary": "count FASTA records"}
+
+
+def _client(tmp_path: Path, service: _FakeService | None = None):
+ from gfa_core.extension_stage_lightweight.api import (
+ LightweightApiSettings,
+ create_lightweight_extension_router,
+ )
+
+ queued: list[dict] = []
+ app = FastAPI()
+ app.include_router(
+ create_lightweight_extension_router(
+ settings=LightweightApiSettings(
+ project_root=tmp_path,
+ store_root=tmp_path / "store",
+ input_root=tmp_path / "input",
+ workspace_root=tmp_path / "workspace",
+ ),
+ service_factory=lambda _settings: service or _FakeService(),
+ enqueue=lambda payload: queued.append(payload) or "celery-test-id",
+ )
+ )
+ return TestClient(app), queued
+
+
+def _receipt(tmp_path: Path, *, token: str = "validate-1") -> None:
+ path = tmp_path / "store" / "operations" / f"{token}.json"
+ path.parent.mkdir(parents=True)
+ path.write_text(
+ json.dumps(
+ {
+ "operation_id": token,
+ "operation_token": token,
+ "extension_id": "count_fasta",
+ "action": "validate",
+ "state": "queued",
+ "status": "queued",
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
+def test_settings_use_explicit_environment_or_current_project_root(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.api import LightweightApiSettings
+
+ settings = LightweightApiSettings.from_environment(
+ environ={
+ "BBAV5_PROJECT_ROOT": str(tmp_path),
+ "BBAV5_LIGHTWEIGHT_EXTENSION_STORE": "isolated-store",
+ "BBAV5_LIGHTWEIGHT_INPUT": "isolated-input",
+ "BBAV5_LIGHTWEIGHT_WORKSPACE": "isolated-workspace",
+ }
+ )
+
+ assert settings.project_root == tmp_path
+ assert settings.store_root == tmp_path / "isolated-store"
+ assert settings.input_root == tmp_path / "isolated-input"
+ assert settings.workspace_root == tmp_path / "isolated-workspace"
+ assert "bbav5_0502_Yang_Tihuan.7880" not in str(settings.project_root)
+
+ project_root_only = LightweightApiSettings.from_environment(
+ environ={"PROJECT_ROOT": str(tmp_path)}
+ )
+ assert project_root_only.project_root == tmp_path
+
+
+def test_worker_maps_logical_job_paths_to_configured_isolated_roots(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.api import (
+ LightweightApiSettings,
+ _runtime_input_path,
+ )
+
+ settings = LightweightApiSettings(
+ tmp_path,
+ tmp_path / "store",
+ tmp_path / "isolated-input",
+ tmp_path / "isolated-workspace",
+ )
+
+ assert _runtime_input_path(settings, "job_1", "input/job_1/sample.fa") == (
+ settings.input_root / "job_1" / "sample.fa"
+ )
+ assert _runtime_input_path(settings, "job_1", "workspace/job_1/sample.fa") == (
+ settings.workspace_root / "job_1" / "sample.fa"
+ )
+
+
+def test_upload_persists_files_and_returns_operation_before_celery_runs(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "count_fasta",
+ "original_command": "bash count.sh input.fa",
+ "operation_token": "upload-1",
+ },
+ files=[
+ ("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
+ ("raw_data_files", ("input.fa", b">a\nAAAA\n", "text/plain")),
+ ],
+ )
+
+ assert response.status_code == 202
+ assert response.json()["operation_id"] == "upload-1"
+ assert response.json()["state"] == "queued"
+ assert len(queued) == 1
+ assert queued[0]["action"] == "upload"
+ assert queued[0]["original_command"] == "bash count.sh input.fa"
+ assert (tmp_path / "store" / "staging" / "upload-1" / "raw_code" / "count.sh").is_file()
+ assert (tmp_path / "store" / "staging" / "upload-1" / "raw_test_data" / "input.fa").is_file()
+
+ duplicate = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "count_fasta",
+ "original_command": "bash count.sh input.fa",
+ "operation_token": "upload-1",
+ },
+ files=[
+ ("raw_code_files", ("count.sh", b"ignored", "text/plain")),
+ ("raw_data_files", ("input.fa", b"ignored", "text/plain")),
+ ],
+ )
+ assert duplicate.status_code == 202
+ assert len(queued) == 1
+
+
+def test_retry_semantic_endpoint_enqueues_only_semantic_generation(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/count_fasta/retry-semantic",
+ json={"operation_token": "semantic-2"},
+ )
+
+ assert response.status_code == 202
+ assert response.json()["operation_id"] == "semantic-2"
+ assert queued == [
+ {
+ "extension_id": "count_fasta",
+ "action": "retry_semantic",
+ "operation_token": "semantic-2",
+ }
+ ]
+
+
+def test_upload_accepts_single_code_entry_without_original_command(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={"extension_id": "code_only", "operation_token": "upload-code-only"},
+ files=[
+ ("raw_code_files", ("run.sh", b"printf 'ok\\n' > result.txt\n", "text/plain")),
+ ("raw_data_files", ("input.fa", b">a\nAAAA\n", "text/plain")),
+ ],
+ )
+
+ assert response.status_code == 202
+ assert queued[0]["original_command"] == "bash run.sh"
+
+
+def test_upload_accepts_original_command_without_code_file(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "command_only",
+ "original_command": "wc -l input.txt",
+ "operation_token": "upload-command-only",
+ },
+ files=[("raw_data_files", ("input.txt", b"one\ntwo\n", "text/plain"))],
+ )
+
+ assert response.status_code == 202
+ assert queued[0]["original_command"] == "bash run.sh"
+ run_sh = tmp_path / "store" / "staging" / "upload-command-only" / "raw_code" / "run.sh"
+ assert run_sh.is_file()
+ assert "wc -l input.txt" in run_sh.read_text(encoding="utf-8")
+
+
+def test_upload_accepts_server_data_file_as_symlink(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+ source = tmp_path / "large" / "input.txt"
+ source.parent.mkdir()
+ source.write_text("one\ntwo\n", encoding="utf-8")
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "server_path",
+ "original_command": "wc -l input.txt",
+ "operation_token": "upload-server-path",
+ "raw_data_paths": str(source),
+ },
+ )
+
+ assert response.status_code == 202
+ assert len(queued) == 1
+ staged = tmp_path / "store" / "staging" / "upload-server-path" / "raw_test_data" / "input.txt"
+ assert staged.is_symlink()
+ assert staged.resolve() == source.resolve()
+
+
+def test_upload_expands_server_data_directory_preserving_relative_layout(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+ source = tmp_path / "large_case"
+ (source / "nested").mkdir(parents=True)
+ (source / "input.fa").write_text(">a\nAAAA\n", encoding="utf-8")
+ (source / "nested" / "model.hmm").write_text("HMMER3/f\n", encoding="utf-8")
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "server_directory",
+ "original_command": "printf ok",
+ "operation_token": "upload-server-directory",
+ "raw_data_paths": str(source),
+ },
+ )
+
+ assert response.status_code == 202
+ assert len(queued) == 1
+ staged = tmp_path / "store" / "staging" / "upload-server-directory" / "raw_test_data"
+ assert (staged / "input.fa").is_symlink()
+ assert (staged / "nested" / "model.hmm").is_symlink()
+
+
+def test_upload_rejects_missing_server_data_path(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+ missing = tmp_path / "missing.fa"
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "missing_server_path",
+ "original_command": "wc -l missing.fa",
+ "operation_token": "upload-missing-server-path",
+ "raw_data_paths": str(missing),
+ },
+ )
+
+ assert response.status_code == 400
+ assert f"测试数据路径不存在:{missing}" in response.json()["detail"]["message"]
+ assert queued == []
+
+
+def test_upload_rejects_uploaded_and_server_path_name_collision(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+ source = tmp_path / "large" / "input.txt"
+ source.parent.mkdir()
+ source.write_text("server\n", encoding="utf-8")
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "data_collision",
+ "original_command": "wc -l input.txt",
+ "operation_token": "upload-data-collision",
+ "raw_data_paths": str(source),
+ },
+ files=[("raw_data_files", ("input.txt", b"upload\n", "text/plain"))],
+ )
+
+ assert response.status_code == 400
+ assert "测试数据文件名冲突:input.txt" in response.json()["detail"]["message"]
+ assert queued == []
+
+
+def test_server_path_submission_runs_real_raw_baseline(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+
+ client, queued = _client(tmp_path)
+ source = tmp_path / "external_data" / "input.fa"
+ source.parent.mkdir()
+ source.write_text(">a\nAAAA\n>b\nBBBB\n", encoding="utf-8")
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={
+ "extension_id": "real_server_path",
+ "operation_token": "upload-real-server-path",
+ "raw_data_paths": str(source),
+ },
+ files=[
+ ("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
+ ],
+ )
+
+ assert response.status_code == 202
+ assert queued[0]["original_command"] == "bash count.sh"
+ staged = tmp_path / "store" / "staging" / "upload-real-server-path"
+ evidence = run_raw_baseline(
+ command=queued[0]["original_command"],
+ raw_code=staged / "raw_code",
+ raw_data=staged / "raw_test_data",
+ run_dir=tmp_path / "real_baseline",
+ )
+
+ assert evidence["success"] is True
+ assert evidence["outputs"]["stdout"]["preview"] == "2"
+ assert (staged / "raw_test_data" / "input.fa").is_symlink()
+ assert source.read_text(encoding="utf-8").startswith(">a")
+
+
+def test_upload_rejects_ambiguous_code_without_original_command(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={"extension_id": "ambiguous", "operation_token": "upload-ambiguous"},
+ files=[
+ ("raw_code_files", ("run_a.sh", b"echo a\n", "text/plain")),
+ ("raw_code_files", ("run_b.sh", b"echo b\n", "text/plain")),
+ ("raw_data_files", ("input.txt", b"data\n", "text/plain")),
+ ],
+ )
+
+ assert response.status_code == 400
+ assert "multiple shell entry candidates" in response.json()["detail"]["message"]
+ assert "provide original_command" in response.json()["detail"]["message"]
+ assert queued == []
+
+
+def test_upload_rejects_when_command_and_code_are_both_missing(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data={"extension_id": "missing_entry", "operation_token": "upload-missing"},
+ files=[("raw_data_files", ("input.txt", b"data\n", "text/plain"))],
+ )
+
+ assert response.status_code == 400
+ assert "no command or code file was provided" in response.json()["detail"]["message"]
+ assert queued == []
+
+
+def test_operation_poll_and_stop_are_isolated_to_lightweight_service(tmp_path: Path) -> None:
+ service = _FakeService()
+ _receipt(tmp_path)
+ client, _queued = _client(tmp_path, service)
+
+ poll = client.get("/api/lightweight-extension-stages/operations/validate-1")
+ assert poll.status_code == 200
+ assert poll.json()["state"] == "running"
+ assert poll.json()["status"] == "validation_running"
+
+ stopped = client.post(
+ "/api/lightweight-extension-stages/count_fasta/operations/validate-1/stop"
+ )
+ assert stopped.status_code == 200
+ assert stopped.json()["stopped"] is True
+ assert service.stops == [("count_fasta", "validate-1")]
+
+
+def test_operation_failure_reports_phase_message_and_logs(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "validation_failed",
+ "error": {"phase": "validation", "message": "input binding failed"},
+ "operations": {
+ "validate-1": {
+ "action": "validate",
+ "state": "completed",
+ "response": {"operation_token": "validate-1", "action": "validate"},
+ }
+ },
+ "validation_runs": [
+ {"run_id": "validation_1", "logs": {"stdout": "logs/stdout.log", "stderr": "logs/stderr.log"}}
+ ],
+ "runtime_runs": [],
+ }
+ )
+ _receipt(tmp_path)
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get("/api/lightweight-extension-stages/operations/validate-1")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["error"] == {"phase": "validation", "message": "input binding failed"}
+ assert payload["logs"] == ["logs/stderr.log", "logs/stdout.log"]
+
+
+def test_result_file_must_be_a_listed_safe_output(tmp_path: Path) -> None:
+ output = tmp_path / "store" / "extensions" / "count_fasta" / "runs" / "validation_1" / "outputs" / "result.txt"
+ output.parent.mkdir(parents=True)
+ output.write_text("answer\n", encoding="utf-8")
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "published",
+ "operations": {},
+ "validation_runs": [
+ {
+ "run_id": "validation_1",
+ "projection": {"files": [{"path": "result.txt", "absolute_path": str(output)}]},
+ }
+ ],
+ "runtime_runs": [],
+ }
+ )
+ client, _queued = _client(tmp_path, service)
+
+ allowed = client.get(
+ "/api/lightweight-extension-stages/count_fasta/files",
+ params={"run_id": "validation_1", "path": "result.txt"},
+ )
+ rejected = client.get(
+ "/api/lightweight-extension-stages/count_fasta/files",
+ params={"run_id": "validation_1", "path": "../result.txt"},
+ )
+
+ assert allowed.status_code == 200
+ assert allowed.text == "answer\n"
+ assert rejected.status_code == 400
+
+
+def test_list_route_reads_only_lightweight_store_records(tmp_path: Path) -> None:
+ record = tmp_path / "store" / "extensions" / "count_fasta" / "extension.json"
+ record.parent.mkdir(parents=True)
+ record.write_text(
+ json.dumps({"extension_id": "count_fasta", "status": "published"}),
+ encoding="utf-8",
+ )
+ client, _queued = _client(tmp_path)
+
+ response = client.get("/api/lightweight-extension-stages/")
+
+ assert response.status_code == 200
+ assert response.json()["extensions"] == [
+ {"extension_id": "count_fasta", "status": "published"}
+ ]
+
+
+def test_invalid_submission_uses_structured_phase_error(tmp_path: Path) -> None:
+ client, _queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/count_fasta/validate",
+ json={"operation_token": "../unsafe"},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == {
+ "phase": "validate",
+ "message": "operation_token must be one safe path component",
+ "logs": [],
+ }
+
+
+def test_same_token_concurrent_reservation_has_one_winner(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.api import (
+ LightweightApiSettings,
+ _reserve_operation,
+ )
+
+ settings = LightweightApiSettings(tmp_path, tmp_path / "store", tmp_path / "input", tmp_path / "workspace")
+ start = threading.Barrier(8)
+ created: list[bool] = []
+
+ def reserve() -> None:
+ start.wait()
+ _receipt, won = _reserve_operation(
+ settings, extension_id="count_fasta", action="validate", token="same-token"
+ )
+ created.append(won)
+
+ workers = [threading.Thread(target=reserve) for _ in range(8)]
+ for worker in workers:
+ worker.start()
+ for worker in workers:
+ worker.join(timeout=3)
+
+ assert sum(created) == 1
+
+
+def test_bad_upload_leaves_no_receipt_and_same_token_can_retry(tmp_path: Path) -> None:
+ client, queued = _client(tmp_path)
+ fields = {
+ "extension_id": "count_fasta",
+ "original_command": "bash count.sh input.fa",
+ "operation_token": "upload-retry",
+ }
+ bad = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data=fields,
+ files=[
+ ("raw_code_files", ("..\\count.sh", b"bad", "text/plain")),
+ ("raw_data_files", ("input.fa", b">a\n", "text/plain")),
+ ],
+ )
+ assert bad.status_code == 400
+ assert not (tmp_path / "store" / "operations" / "upload-retry.json").exists()
+ good = client.post(
+ "/api/lightweight-extension-stages/upload",
+ data=fields,
+ files=[
+ ("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
+ ("raw_data_files", ("input.fa", b">a\n", "text/plain")),
+ ],
+ )
+
+ assert good.status_code == 202
+ assert len(queued) == 1
+
+
+def test_generic_intent_returns_standard_usage_without_extension_lookup(tmp_path: Path) -> None:
+ client, _queued = _client(tmp_path)
+
+ response = client.post(
+ "/api/lightweight-extension-stages/intent", json={"message": "如何使用拓展分析阶段?"}
+ )
+
+ assert response.status_code == 200
+ assert "上传" in response.json()["usage"]
+ assert "自然语言" in response.json()["usage"]
+
+
+def test_poll_keeps_operation_snapshot_and_reports_later_extension_status(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "published",
+ "operations": {
+ "validate-1": {
+ "action": "validate",
+ "state": "completed",
+ "run_id": "validation_1",
+ "response": {"status": "result_review_pending", "run_id": "validation_1"},
+ }
+ },
+ "validation_runs": [{"run_id": "validation_1", "success": True, "contract_passed": True}],
+ "runtime_runs": [],
+ }
+ )
+ _receipt(tmp_path)
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get("/api/lightweight-extension-stages/operations/validate-1")
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "result_review_pending"
+ assert response.json()["extension_status"] == "published"
+
+
+def test_poll_reports_failed_runtime_run_even_when_extension_is_published(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "published",
+ "operations": {
+ "validate-1": {"action": "validate", "state": "completed", "response": {}},
+ "run-1": {
+ "action": "run",
+ "state": "completed",
+ "run_id": "runtime_1",
+ "response": {"status": "published", "run_id": "runtime_1"},
+ },
+ },
+ "validation_runs": [],
+ "runtime_runs": [
+ {
+ "run_id": "runtime_1",
+ "success": False,
+ "contract_passed": False,
+ "binding": {"status": "blocked", "error": {"message": "required input missing"}},
+ "logs": {"stderr": "logs/runtime.stderr.log"},
+ }
+ ],
+ }
+ )
+ _receipt(tmp_path, token="run-1")
+ receipt = tmp_path / "store" / "operations" / "run-1.json"
+ receipt.write_text(
+ json.dumps({"operation_id": "run-1", "operation_token": "run-1", "extension_id": "count_fasta", "action": "run", "state": "completed", "status": "published"}),
+ encoding="utf-8",
+ )
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get("/api/lightweight-extension-stages/operations/run-1")
+
+ assert response.status_code == 200
+ assert response.json()["outcome"] == "failed"
+ assert response.json()["error"] == {
+ "phase": "runtime",
+ "message": "required input missing",
+ "logs": ["logs/runtime.stderr.log"],
+ }
+
+
+def test_file_download_rejects_recorded_path_outside_expected_output_root(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "published",
+ "operations": {},
+ "validation_runs": [
+ {
+ "run_id": "validation_1",
+ "projection": {"files": [{"path": "result.txt", "absolute_path": "/etc/hosts"}]},
+ }
+ ],
+ "runtime_runs": [],
+ }
+ )
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get(
+ "/api/lightweight-extension-stages/count_fasta/files",
+ params={"run_id": "validation_1", "path": "result.txt"},
+ )
+
+ assert response.status_code == 400
+
+
+def test_file_download_rejects_symlink_escape_from_expected_output_root(tmp_path: Path) -> None:
+ output = tmp_path / "store" / "extensions" / "count_fasta" / "runs" / "validation_1" / "outputs" / "result.txt"
+ output.parent.mkdir(parents=True)
+ output.symlink_to("/etc/hosts")
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "published",
+ "operations": {},
+ "validation_runs": [
+ {
+ "run_id": "validation_1",
+ "projection": {"files": [{"path": "result.txt", "absolute_path": str(output)}]},
+ }
+ ],
+ "runtime_runs": [],
+ }
+ )
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get(
+ "/api/lightweight-extension-stages/count_fasta/files",
+ params={"run_id": "validation_1", "path": "result.txt"},
+ )
+
+ assert response.status_code == 400
+
+
+def test_old_upload_poll_does_not_project_a_later_baseline_run(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "confirmation_pending",
+ "operations": {
+ "upload-old": {
+ "action": "upload",
+ "state": "completed",
+ "run_id": "baseline_old",
+ "response": {"status": "baseline_failed", "run_id": "baseline_old"},
+ },
+ "rebuild-new": {
+ "action": "rebuild",
+ "state": "completed",
+ "run_id": "baseline_new",
+ "response": {"status": "confirmation_pending", "run_id": "baseline_new"},
+ },
+ },
+ "baseline": {
+ "run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_new"),
+ "success": True,
+ "logs": {"stdout": "logs/new-baseline.stdout.log"},
+ },
+ "validation_runs": [],
+ "runtime_runs": [],
+ }
+ )
+ path = tmp_path / "store" / "operations" / "upload-old.json"
+ path.parent.mkdir(parents=True)
+ path.write_text(
+ json.dumps({"operation_id": "upload-old", "operation_token": "upload-old", "extension_id": "count_fasta", "action": "upload", "state": "completed", "status": "baseline_failed"}),
+ encoding="utf-8",
+ )
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get("/api/lightweight-extension-stages/operations/upload-old")
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "baseline_failed"
+ assert "outcome" not in response.json()
+ assert "logs" not in response.json()
+
+
+def test_upload_streaming_copy_uses_bounded_reads_and_preserves_content(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.api import UPLOAD_CHUNK_BYTES, _copy_upload_file
+
+ class _Upload:
+ filename = "large.bin"
+
+ def __init__(self, content: bytes) -> None:
+ self.content = content
+ self.offset = 0
+ self.read_sizes: list[int] = []
+
+ async def read(self, size: int = -1) -> bytes:
+ self.read_sizes.append(size)
+ chunk = self.content[self.offset : self.offset + size]
+ self.offset += len(chunk)
+ return chunk
+
+ content = b"A" * (UPLOAD_CHUNK_BYTES * 2 + 17)
+ upload = _Upload(content)
+ destination = tmp_path / "large.bin"
+
+ asyncio.run(_copy_upload_file(upload, destination))
+
+ assert destination.read_bytes() == content
+ assert upload.read_sizes == [UPLOAD_CHUNK_BYTES] * 4
+
+
+def test_old_upload_poll_is_not_polluted_by_later_failed_rebuild(tmp_path: Path) -> None:
+ service = _FakeService(
+ {
+ "extension_id": "count_fasta",
+ "status": "baseline_failed",
+ "error": {"phase": "baseline", "message": "later rebuild failed"},
+ "operations": {
+ "upload-old": {
+ "action": "upload",
+ "state": "completed",
+ "run_id": "baseline_old",
+ "response": {"status": "baseline_failed", "run_id": "baseline_old"},
+ },
+ "rebuild-new": {
+ "action": "rebuild",
+ "state": "completed",
+ "run_id": "baseline_new",
+ "response": {"status": "baseline_failed", "run_id": "baseline_new"},
+ },
+ },
+ "baseline": {
+ "run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_new"),
+ "success": False,
+ "logs": {"stderr": "logs/new-baseline.stderr.log"},
+ },
+ "validation_runs": [],
+ "runtime_runs": [],
+ }
+ )
+ path = tmp_path / "store" / "operations" / "upload-old.json"
+ path.parent.mkdir(parents=True)
+ path.write_text(
+ json.dumps({"operation_id": "upload-old", "operation_token": "upload-old", "extension_id": "count_fasta", "action": "upload", "state": "completed", "status": "baseline_failed"}),
+ encoding="utf-8",
+ )
+ client, _queued = _client(tmp_path, service)
+
+ response = client.get("/api/lightweight-extension-stages/operations/upload-old")
+
+ assert response.status_code == 200
+ assert "outcome" not in response.json()
+ assert "error" not in response.json()
+ assert "logs" not in response.json()
+
+
+def test_current_pre_evidence_baseline_failure_is_saved_on_its_receipt(
+ tmp_path: Path, monkeypatch
+) -> None:
+ import gfa_core.extension_stage_lightweight.api as api
+
+ settings = api.LightweightApiSettings(
+ project_root=tmp_path,
+ store_root=tmp_path / "store",
+ input_root=tmp_path / "input",
+ workspace_root=tmp_path / "workspace",
+ )
+ api._write_json(
+ api._receipt_path(settings, "upload-current"),
+ {"operation_id": "upload-current", "operation_token": "upload-current", "extension_id": "count_fasta", "action": "upload", "state": "queued", "status": "queued"},
+ )
+ record = {
+ "extension_id": "count_fasta",
+ "status": "baseline_failed",
+ "error": {"phase": "baseline", "message": "command failed before evidence"},
+ "operations": {
+ "upload-current": {
+ "action": "upload",
+ "state": "completed",
+ "run_id": "baseline_current",
+ "response": {"status": "baseline_failed", "run_id": "baseline_current"},
+ }
+ },
+ "validation_runs": [],
+ "runtime_runs": [],
+ }
+
+ class _CurrentFailureService:
+ def upload(self, **_kwargs):
+ return {"status": "baseline_failed"}
+
+ def get(self, extension_id: str):
+ assert extension_id == "count_fasta"
+ return record
+
+ monkeypatch.setattr(api.LightweightApiSettings, "from_environment", classmethod(lambda _cls: settings))
+ monkeypatch.setattr(api, "_service_for", lambda _settings: _CurrentFailureService())
+
+ receipt = api.execute_queued_operation(
+ {
+ "action": "upload",
+ "extension_id": "count_fasta",
+ "operation_token": "upload-current",
+ "original_command": "bash count.sh input.fa",
+ }
+ )
+
+ assert receipt["error"] == {
+ "phase": "baseline",
+ "message": "command failed before evidence",
+ "logs": [],
+ }
+
+
+def test_current_pre_evidence_rebuild_failure_does_not_inherit_prior_baseline_logs(
+ tmp_path: Path, monkeypatch
+) -> None:
+ import gfa_core.extension_stage_lightweight.api as api
+
+ settings = api.LightweightApiSettings(tmp_path, tmp_path / "store", tmp_path / "input", tmp_path / "workspace")
+ api._write_json(
+ api._receipt_path(settings, "rebuild-current"),
+ {"operation_id": "rebuild-current", "operation_token": "rebuild-current", "extension_id": "count_fasta", "action": "rebuild", "state": "queued", "status": "queued"},
+ )
+ record = {
+ "extension_id": "count_fasta",
+ "status": "baseline_failed",
+ "error": {"phase": "baseline", "message": "rebuild failed before evidence"},
+ "operations": {
+ "rebuild-current": {
+ "action": "rebuild",
+ "state": "completed",
+ "run_id": "baseline_current",
+ "response": {"status": "baseline_failed", "run_id": "baseline_current"},
+ }
+ },
+ "baseline": {
+ "run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_prior"),
+ "logs": {"stderr": "logs/prior-baseline.stderr.log"},
+ },
+ "validation_runs": [],
+ "runtime_runs": [],
+ }
+
+ class _CurrentFailureService:
+ def rebuild(self, *_args, **_kwargs):
+ return {"status": "baseline_failed"}
+
+ def get(self, extension_id: str):
+ assert extension_id == "count_fasta"
+ return record
+
+ monkeypatch.setattr(api.LightweightApiSettings, "from_environment", classmethod(lambda _cls: settings))
+ monkeypatch.setattr(api, "_service_for", lambda _settings: _CurrentFailureService())
+
+ receipt = api.execute_queued_operation(
+ {"action": "rebuild", "extension_id": "count_fasta", "operation_token": "rebuild-current"}
+ )
+
+ assert receipt["error"]["logs"] == []
+
+
+def test_celery_task_is_explicitly_unlimited_and_main_registers_only_new_router() -> None:
+ root = Path(__file__).resolve().parents[2]
+ tasks = (root / "tasks.py").read_text(encoding="utf-8")
+ celery = (root / "celery_app.py").read_text(encoding="utf-8")
+ main = (root / "apps/web/vue3/main.py").read_text(encoding="utf-8")
+
+ assert 'name="tasks.execute_lightweight_extension_operation"' in tasks
+ assert "time_limit=None" in tasks
+ assert "soft_time_limit=None" in tasks
+ assert '"tasks.execute_lightweight_extension_operation"' in celery
+ assert "create_lightweight_extension_router" in main
+ assert "/api/extension-stages" not in (root / "gfa_core/extension_stage_lightweight/api.py").read_text(encoding="utf-8")
diff --git a/tests/unit/test_extension_stage_lightweight_command_model.py b/tests/unit/test_extension_stage_lightweight_command_model.py
new file mode 100644
index 000000000..80a3a19fc
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_command_model.py
@@ -0,0 +1,373 @@
+from pathlib import Path
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.command_model import build_command_model
+
+
+def _file_state(*paths: str) -> dict[str, dict[str, object]]:
+ return {path: {"kind": "file", "size": 1, "sha256": path} for path in paths}
+
+
+def test_direct_command_retains_all_flags_and_binds_input(tmp_path: Path) -> None:
+ model = build_command_model(
+ command="iqtree -s Ft.SPL.pep.mfa -m MFP -bb 1000 -nt 4 -pre Ft.SPL.iqtree",
+ work_dir=tmp_path,
+ before_files=_file_state("Ft.SPL.pep.mfa"),
+ output_files=["Ft.SPL.iqtree.treefile", "Ft.SPL.iqtree.iqtree"],
+ code_files=[],
+ )
+
+ assert model["source_scripts"] == []
+ assert len(model["commands"]) == 1
+ command = model["commands"][0]
+ assert command["tool"] == "iqtree"
+ assert [item["flag"] for item in command["arguments"]] == [
+ "-s",
+ "-m",
+ "-bb",
+ "-nt",
+ "-pre",
+ ]
+ assert command["inputs"] == [
+ {
+ "path": "Ft.SPL.pep.mfa",
+ "source": "baseline_input",
+ "flag": "-s",
+ "token_index": 2,
+ }
+ ]
+ assert [(item["flag"], item["value"], item["kind"]) for item in command["parameters"]] == [
+ ("-m", "MFP", "flag_value"),
+ ("-bb", "1000", "flag_value"),
+ ("-nt", "4", "flag_value"),
+ ("-pre", "Ft.SPL.iqtree", "flag_value"),
+ ]
+ assert command["outputs"] == [
+ "Ft.SPL.iqtree.iqtree",
+ "Ft.SPL.iqtree.treefile",
+ ]
+
+
+def test_shell_script_is_expanded_per_command_with_switch_and_generated_input(
+ tmp_path: Path,
+) -> None:
+ script = tmp_path / "run.sh"
+ script.write_text(
+ """#!/usr/bin/env bash
+hmmsearch --cut_tc \\
+ --domtblout Ft.SPL.domtblout \\
+ -o Ft.SPL.hmmout \\
+ SBP.hmm Ft.pep.fasta
+python parse_hmmer.py Ft.SPL.domtblout > parsed.tsv
+""",
+ encoding="utf-8",
+ )
+ (tmp_path / "parse_hmmer.py").write_text("", encoding="utf-8")
+
+ model = build_command_model(
+ command="bash run.sh",
+ work_dir=tmp_path,
+ before_files=_file_state(
+ "run.sh", "parse_hmmer.py", "SBP.hmm", "Ft.pep.fasta"
+ ),
+ output_files=["Ft.SPL.domtblout", "Ft.SPL.hmmout", "parsed.tsv"],
+ code_files=["run.sh", "parse_hmmer.py"],
+ )
+
+ assert model["source_scripts"] == ["run.sh"]
+ assert [item["tool"] for item in model["commands"]] == ["hmmsearch", "python"]
+
+ search, parser = model["commands"]
+ assert [(item["path"], item["source"]) for item in search["inputs"]] == [
+ ("SBP.hmm", "baseline_input"),
+ ("Ft.pep.fasta", "baseline_input"),
+ ]
+ assert [(item["flag"], item["kind"], item["value"]) for item in search["parameters"]] == [
+ ("--cut_tc", "switch", "--cut_tc"),
+ ("--domtblout", "flag_value", "Ft.SPL.domtblout"),
+ ("-o", "flag_value", "Ft.SPL.hmmout"),
+ ]
+ assert search["outputs"] == ["Ft.SPL.domtblout", "Ft.SPL.hmmout"]
+
+ assert parser["source_files"] == ["parse_hmmer.py"]
+ assert parser["inputs"] == [
+ {
+ "path": "Ft.SPL.domtblout",
+ "source": "generated_input",
+ "flag": None,
+ "token_index": 2,
+ }
+ ]
+ assert parser["outputs"] == ["parsed.tsv"]
+ assert all(item["path"] != "run.sh" for item in parser["inputs"])
+
+
+def test_output_binding_uses_observed_artifact_not_known_flag_name(tmp_path: Path) -> None:
+ model = build_command_model(
+ command="unknown_tool --destination result.custom input.weird",
+ work_dir=tmp_path,
+ before_files=_file_state("input.weird"),
+ output_files=["result.custom"],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert command["parameters"] == [
+ {
+ "kind": "flag_value",
+ "flag": "--destination",
+ "value": "result.custom",
+ "token_index": 1,
+ "value_role": "output_reference",
+ }
+ ]
+ assert command["outputs"] == ["result.custom"]
+ assert command["inputs"][0]["path"] == "input.weird"
+
+
+@pytest.mark.parametrize("quote", [pytest.param("'", id="single"), pytest.param('"', id="double")])
+def test_quoted_value_before_known_input_preserves_ambiguous_grouping(
+ tmp_path: Path, quote: str,
+) -> None:
+ model = build_command_model(
+ command=f"unknown_tool -x {quote}rewrite program{quote} input.txt",
+ work_dir=tmp_path,
+ before_files=_file_state("input.txt"),
+ output_files=[],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert command["arguments"] == [
+ {
+ "kind": "switch",
+ "flag": "-x",
+ "value": "-x",
+ "token_index": 1,
+ "grouping_confidence": "ambiguous",
+ }
+ ]
+ assert command["parameters"] == [
+ {
+ "kind": "switch",
+ "flag": "-x",
+ "value": "-x",
+ "token_index": 1,
+ "grouping_confidence": "ambiguous",
+ },
+ {
+ "kind": "positional",
+ "flag": None,
+ "value": "rewrite program",
+ "token_index": 2,
+ "position": 0,
+ "value_role": "value",
+ "grouping_confidence": "ambiguous",
+ },
+ ]
+ assert command["inputs"] == [
+ {
+ "path": "input.txt",
+ "source": "baseline_input",
+ "flag": None,
+ "token_index": 3,
+ }
+ ]
+
+
+def test_quoted_candidate_without_later_input_keeps_flag_value(tmp_path: Path) -> None:
+ model = build_command_model(
+ command="unknown_tool -x 'rewrite program'",
+ work_dir=tmp_path,
+ before_files={},
+ output_files=[],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert command["arguments"] == [
+ {
+ "kind": "flag_value",
+ "flag": "-x",
+ "value": "rewrite program",
+ "token_index": 1,
+ "value_role": "value",
+ }
+ ]
+ assert command["parameters"] == command["arguments"]
+
+
+@pytest.mark.parametrize(
+ ("candidate", "before_paths", "output_files", "code_files", "value_role"),
+ [
+ pytest.param(
+ "known input.txt",
+ ("known input.txt", "later.txt"),
+ [],
+ [],
+ "input",
+ id="baseline-input",
+ ),
+ pytest.param(
+ "script file.py",
+ ("script file.py", "later.txt"),
+ [],
+ ["script file.py"],
+ "value",
+ id="code-file",
+ ),
+ pytest.param(
+ "result file.txt",
+ ("later.txt",),
+ ["result file.txt"],
+ [],
+ "output_reference",
+ id="observed-output",
+ ),
+ ],
+)
+def test_quoted_candidate_reference_keeps_flag_value(
+ tmp_path: Path,
+ candidate: str,
+ before_paths: tuple[str, ...],
+ output_files: list[str],
+ code_files: list[str],
+ value_role: str,
+) -> None:
+ model = build_command_model(
+ command=f"unknown_tool -x '{candidate}' later.txt",
+ work_dir=tmp_path,
+ before_files=_file_state(*before_paths),
+ output_files=output_files,
+ code_files=code_files,
+ )
+
+ command = model["commands"][0]
+ assert command["arguments"][0] == {
+ "kind": "flag_value",
+ "flag": "-x",
+ "value": candidate,
+ "token_index": 1,
+ "value_role": value_role,
+ }
+ assert "grouping_confidence" not in command["arguments"][0]
+ assert any(item["path"] == "later.txt" for item in command["inputs"])
+
+
+def test_unaligned_posix_and_non_posix_tokens_keep_flag_value(tmp_path: Path) -> None:
+ model = build_command_model(
+ command=r"unknown_tool -x rewrite\ program input.txt",
+ work_dir=tmp_path,
+ before_files=_file_state("input.txt"),
+ output_files=[],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert command["arguments"][0] == {
+ "kind": "flag_value",
+ "flag": "-x",
+ "value": "rewrite program",
+ "token_index": 1,
+ "value_role": "value",
+ }
+ assert "grouping_confidence" not in command["arguments"][0]
+
+
+def test_escaped_literal_quotes_in_argv_are_not_outer_shell_quotes(
+ tmp_path: Path,
+) -> None:
+ model = build_command_model(
+ command=r'unknown_tool -x \"rewrite\ program\" input.txt',
+ work_dir=tmp_path,
+ before_files=_file_state("input.txt"),
+ output_files=[],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert command["arguments"][0] == {
+ "kind": "flag_value",
+ "flag": "-x",
+ "value": '"rewrite program"',
+ "token_index": 1,
+ "value_role": "value",
+ }
+ assert "grouping_confidence" not in command["arguments"][0]
+ assert command["inputs"][0]["path"] == "input.txt"
+
+
+def test_consecutive_ambiguous_pairs_keep_all_tokens_and_positions(
+ tmp_path: Path,
+) -> None:
+ model = build_command_model(
+ command="unknown_tool -x 'first program' -y 'second program' input.txt",
+ work_dir=tmp_path,
+ before_files=_file_state("input.txt"),
+ output_files=[],
+ code_files=[],
+ )
+
+ command = model["commands"][0]
+ assert [
+ (
+ item["kind"],
+ item["flag"],
+ item["value"],
+ item.get("position"),
+ item.get("grouping_confidence"),
+ )
+ for item in command["parameters"]
+ ] == [
+ ("switch", "-x", "-x", None, "ambiguous"),
+ ("positional", None, "first program", 0, "ambiguous"),
+ ("switch", "-y", "-y", None, "ambiguous"),
+ ("positional", None, "second program", 1, "ambiguous"),
+ ]
+ assert [(item["flag"], item["token_index"]) for item in command["arguments"]] == [
+ ("-x", 1),
+ ("-y", 3),
+ ]
+ assert command["inputs"][0]["token_index"] == 5
+
+
+def test_generated_input_evidence_excludes_candidate_and_triggers_later_pair(
+ tmp_path: Path,
+) -> None:
+ model = build_command_model(
+ command=(
+ "unknown_tool input.txt > 'generated candidate.txt' && "
+ "unknown_tool -x 'generated candidate.txt' input.txt && "
+ "unknown_tool input.txt > generated_later.txt && "
+ "unknown_tool -x 'rewrite program' generated_later.txt"
+ ),
+ work_dir=tmp_path,
+ before_files=_file_state("input.txt"),
+ output_files=["generated candidate.txt", "generated_later.txt"],
+ code_files=[],
+ )
+
+ candidate_command = model["commands"][1]
+ assert candidate_command["arguments"][0]["kind"] == "flag_value"
+ assert candidate_command["arguments"][0]["value_role"] == "input"
+ assert "grouping_confidence" not in candidate_command["arguments"][0]
+ assert candidate_command["inputs"][0]["source"] == "generated_input"
+
+ later_command = model["commands"][3]
+ assert [item["kind"] for item in later_command["parameters"]] == [
+ "switch",
+ "positional",
+ ]
+ assert all(
+ item["grouping_confidence"] == "ambiguous"
+ for item in later_command["parameters"]
+ )
+ assert later_command["inputs"] == [
+ {
+ "path": "generated_later.txt",
+ "source": "generated_input",
+ "flag": None,
+ "token_index": 3,
+ }
+ ]
diff --git a/tests/unit/test_extension_stage_lightweight_confirmation.py b/tests/unit/test_extension_stage_lightweight_confirmation.py
new file mode 100644
index 000000000..6268c1eee
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_confirmation.py
@@ -0,0 +1,287 @@
+from __future__ import annotations
+
+from copy import deepcopy
+import importlib
+
+import pytest
+
+
+def _module():
+ return importlib.import_module(
+ "gfa_core.extension_stage_lightweight.confirmation"
+ )
+
+
+def _facts_and_candidate() -> tuple[dict, dict]:
+ facts = {
+ "facts_sha256": "facts-v1",
+ "commands": [
+ {
+ "inputs": [{"fact_id": "step_001:input:001"}],
+ "parameters": [{"fact_id": "step_001:parameter:001"}],
+ }
+ ],
+ "outputs": {
+ "files": [{"fact_id": "output:file:001"}],
+ "stdout": {"present": False},
+ },
+ }
+ candidate = {
+ "facts_sha256": "facts-v1",
+ "function_summary": "Search sequences with a fixed HMM profile.",
+ "inputs": [
+ {
+ "fact_id": "step_001:input:001",
+ "role": "hmm_profile",
+ "description": "HMM profile used by the search.",
+ "replaceability": "fixed_resource",
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": "step_001:parameter:001",
+ "display_name": "trusted cutoff",
+ "description": "Use the trusted cutoff stored in the model.",
+ }
+ ],
+ "outputs": [
+ {
+ "fact_id": "output:file:001",
+ "description": "Domain table produced by the search.",
+ "result_role": "final",
+ }
+ ],
+ }
+ return facts, candidate
+
+
+def test_confirm_revise_and_cancel_have_distinct_non_ambiguous_states() -> None:
+ module = _module()
+ facts, candidate = _facts_and_candidate()
+ repair_history = [
+ {
+ "attempt": 1,
+ "status": "success",
+ "diff": [
+ {
+ "section": "inputs",
+ "fact_id": "step_001:input:001",
+ "field": "replaceability",
+ "before": "replaceable",
+ "after": "fixed_resource",
+ }
+ ],
+ }
+ ]
+ confirmation = module.create_confirmation(
+ execution_facts=facts,
+ candidate=candidate,
+ repair_history=repair_history,
+ )
+
+ assert confirmation["status"] == "pending"
+ assert confirmation["accepted"] is False
+ assert confirmation["facts_sha256"] == "facts-v1"
+ assert len(confirmation["candidate_sha256"]) == 64
+ assert confirmation["candidate_version"].startswith("semantic_")
+ assert confirmation["summary"] == {
+ "function_summary": candidate["function_summary"],
+ "input_count": 1,
+ "parameter_count": 1,
+ "output_count": 1,
+ "revision_diff": repair_history[0]["diff"],
+ }
+
+ confirmed = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="confirm",
+ execution_facts=facts,
+ candidate=candidate,
+ )
+ assert confirmed["status"] == "confirmed"
+ assert confirmed["accepted"] is True
+ assert module.is_candidate_accepted(
+ confirmation=confirmed,
+ execution_facts=facts,
+ candidate=candidate,
+ )
+
+ cancelled = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="cancel",
+ execution_facts=facts,
+ candidate=candidate,
+ )
+ assert cancelled["status"] == "cancelled"
+ assert cancelled["accepted"] is False
+
+ revised = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="revise",
+ execution_facts=facts,
+ candidate=candidate,
+ feedback="HMM 文件应标记为固定资源",
+ revision_target="inputs.replaceability",
+ )
+ assert revised["status"] == "revision_requested"
+ assert revised["accepted"] is False
+ assert revised["route"] == "semantic_repair"
+ assert revised["feedback"] == "HMM 文件应标记为固定资源"
+ assert confirmation["status"] == "pending"
+
+ with pytest.raises(ValueError, match="non-empty feedback"):
+ module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="revise",
+ execution_facts=facts,
+ candidate=candidate,
+ feedback=" ",
+ revision_target="inputs.description",
+ )
+
+
+def test_confirmation_cannot_be_reused_after_facts_or_candidate_change() -> None:
+ module = _module()
+ facts, candidate = _facts_and_candidate()
+ confirmation = module.create_confirmation(
+ execution_facts=facts,
+ candidate=candidate,
+ )
+ confirmed = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="confirm",
+ execution_facts=facts,
+ candidate=candidate,
+ )
+
+ changed_candidate = deepcopy(candidate)
+ changed_candidate["function_summary"] = "Revised scientific summary."
+ stale_candidate = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="confirm",
+ execution_facts=facts,
+ candidate=changed_candidate,
+ )
+ assert stale_candidate["status"] == "stale"
+ assert stale_candidate["stale_reason"] == "candidate_changed"
+ assert stale_candidate["accepted"] is False
+ assert not module.is_candidate_accepted(
+ confirmation=confirmed,
+ execution_facts=facts,
+ candidate=changed_candidate,
+ )
+
+ changed_facts = deepcopy(facts)
+ changed_facts["facts_sha256"] = "facts-v2"
+ stale_facts = module.apply_confirmation_action(
+ confirmation=confirmation,
+ action="confirm",
+ execution_facts=changed_facts,
+ candidate=candidate,
+ )
+ assert stale_facts["status"] == "stale"
+ assert stale_facts["stale_reason"] == "facts_changed"
+ assert stale_facts["accepted"] is False
+
+
+def test_revised_candidate_is_a_new_pending_version() -> None:
+ module = _module()
+ facts, candidate = _facts_and_candidate()
+ original = module.create_confirmation(
+ execution_facts=facts,
+ candidate=candidate,
+ )
+ revised_candidate = deepcopy(candidate)
+ revised_candidate["inputs"][0]["description"] = (
+ "Bundled HMM profile used as a fixed scientific resource."
+ )
+ revised = module.create_confirmation(
+ execution_facts=facts,
+ candidate=revised_candidate,
+ repair_history=[
+ {
+ "attempt": 1,
+ "status": "success",
+ "diff": [
+ {
+ "section": "inputs",
+ "fact_id": "step_001:input:001",
+ "field": "description",
+ "before": candidate["inputs"][0]["description"],
+ "after": revised_candidate["inputs"][0]["description"],
+ }
+ ],
+ }
+ ],
+ )
+
+ assert revised["candidate_version"] != original["candidate_version"]
+ assert revised["status"] == "pending"
+ assert revised["accepted"] is False
+
+
+@pytest.mark.parametrize(
+ "target",
+ [
+ "semantic",
+ "function_summary",
+ "inputs.role",
+ "inputs.description",
+ "inputs.replaceability",
+ "parameters.display_name",
+ "parameters.description",
+ "outputs.description",
+ "outputs.result_role",
+ ],
+)
+def test_semantic_revision_targets_return_to_repair(target: str) -> None:
+ module = _module()
+ facts, candidate = _facts_and_candidate()
+ result = module.apply_confirmation_action(
+ confirmation=module.create_confirmation(
+ execution_facts=facts,
+ candidate=candidate,
+ ),
+ action="revise",
+ execution_facts=facts,
+ candidate=candidate,
+ feedback="请按我的科学语义说明修订",
+ revision_target=target,
+ )
+
+ assert result["status"] == "revision_requested"
+ assert result["route"] == "semantic_repair"
+ assert result["accepted"] is False
+
+
+@pytest.mark.parametrize(
+ "target",
+ [
+ "execution_facts.original_command",
+ "execution_facts.path",
+ "execution_facts.flag",
+ "execution_facts.value",
+ "execution_facts.default_value",
+ "execution_facts.observed_output",
+ ],
+)
+def test_fact_disputes_require_new_evidence_and_never_enter_deepseek(
+ target: str,
+) -> None:
+ module = _module()
+ facts, candidate = _facts_and_candidate()
+ result = module.apply_confirmation_action(
+ confirmation=module.create_confirmation(
+ execution_facts=facts,
+ candidate=candidate,
+ ),
+ action="revise",
+ execution_facts=facts,
+ candidate=candidate,
+ feedback="这个执行事实不正确",
+ revision_target=target,
+ )
+
+ assert result["status"] == "fact_dispute"
+ assert result["route"] == "recollect_execution_evidence"
+ assert result["accepted"] is False
diff --git a/tests/unit/test_extension_stage_lightweight_deepseek_semantics.py b/tests/unit/test_extension_stage_lightweight_deepseek_semantics.py
new file mode 100644
index 000000000..b7f50c671
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_deepseek_semantics.py
@@ -0,0 +1,364 @@
+from __future__ import annotations
+
+import importlib
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.converter import build_execution_facts
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+
+
+def _module():
+ return importlib.import_module(
+ "gfa_core.extension_stage_lightweight.deepseek_semantics"
+ )
+
+
+def _write(root: Path, relative: str, content: str | bytes) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(content, bytes):
+ path.write_bytes(content)
+ else:
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _facts_and_code(tmp_path: Path) -> tuple[dict, Path]:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "cp input.fa result.txt\nprintf 'baseline complete\\n'\n",
+ )
+ _write(raw_code, "large_helper.py", "x = 1\n" * 7000)
+ _write(raw_code, "compiled.bin", b"\x00\x01\x02\xff")
+ _write(raw_data, "input.fa", ">a\nAAAA\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ return build_execution_facts(evidence), raw_code
+
+
+def _candidate(facts: dict) -> dict:
+ output_facts = list(facts["outputs"]["files"])
+ if facts["outputs"]["stdout"]["present"]:
+ output_facts.append(facts["outputs"]["stdout"])
+ return {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy a FASTA file and report completion.",
+ "inputs": [
+ {
+ "fact_id": item["fact_id"],
+ "role": "input_sequences",
+ "description": "Uploaded FASTA sequences.",
+ "replaceability": "replaceable",
+ }
+ for command in facts["commands"]
+ for item in command["inputs"]
+ ],
+ "parameters": [
+ {
+ "fact_id": item["fact_id"],
+ "display_name": "explicit command value",
+ "description": "Value present in the original command.",
+ }
+ for command in facts["commands"]
+ for item in command["parameters"]
+ ],
+ "outputs": [
+ {
+ "fact_id": item["fact_id"],
+ "description": "Observed analysis output.",
+ "result_role": "final",
+ }
+ for item in output_facts
+ ],
+ }
+
+
+def _env_file(tmp_path: Path, *, include_key: bool = True) -> Path:
+ lines = [
+ "NEW_API_BASE_URL=https://api.example.test/v1",
+ "NEW_API_MODEL=deepseek-test",
+ ]
+ if include_key:
+ lines.append("NEW_API_KEY=secret-test-key")
+ return _write(tmp_path, ".env", "\n".join(lines) + "\n")
+
+
+class _FakeCompletions:
+ def __init__(self, response=None, error: Exception | None = None) -> None:
+ self.response = response
+ self.error = error
+ self.requests: list[dict] = []
+
+ def create(self, **kwargs):
+ self.requests.append(kwargs)
+ if self.error is not None:
+ raise self.error
+ return self.response
+
+
+class _FakeClient:
+ def __init__(self, response=None, error: Exception | None = None) -> None:
+ self.completions = _FakeCompletions(response=response, error=error)
+ self.chat = SimpleNamespace(completions=self.completions)
+
+
+def _response(content: str):
+ return SimpleNamespace(
+ id="request-123",
+ model="deepseek-test",
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(content=content), finish_reason="stop"
+ )
+ ],
+ usage=SimpleNamespace(
+ prompt_tokens=120,
+ completion_tokens=80,
+ total_tokens=200,
+ ),
+ )
+
+
+@pytest.mark.parametrize(
+ ("environ", "create_file", "expected_fragment"),
+ [
+ ({}, False, "BBAV5_PROJECT_ENV_FILE"),
+ ({"BBAV5_PROJECT_ENV_FILE": "missing.env"}, False, "does not exist"),
+ ({"BBAV5_PROJECT_ENV_FILE": "configured.env"}, True, "API key"),
+ ],
+)
+def test_configuration_requires_one_explicit_project_env_file(
+ tmp_path: Path,
+ environ: dict[str, str],
+ create_file: bool,
+ expected_fragment: str,
+) -> None:
+ module = _module()
+ resolved = dict(environ)
+ if "BBAV5_PROJECT_ENV_FILE" in resolved:
+ env_path = tmp_path / resolved["BBAV5_PROJECT_ENV_FILE"]
+ resolved["BBAV5_PROJECT_ENV_FILE"] = str(env_path)
+ if create_file:
+ _env_file(tmp_path, include_key=False).rename(env_path)
+
+ with pytest.raises(module.DeepSeekSemanticError) as raised:
+ module.load_deepseek_config(resolved)
+
+ assert raised.value.code == "configuration_error"
+ assert expected_fragment in str(raised.value)
+
+
+def test_success_request_contains_locked_facts_and_bounded_code_preview(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ candidate = _candidate(facts)
+ api_candidate = dict(candidate)
+ api_candidate.pop("facts_sha256")
+ fake = _FakeClient(response=_response(json.dumps(api_candidate)))
+ env_file = _env_file(tmp_path)
+
+ actual, metadata = module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
+ client_factory=lambda config: fake,
+ attempt=3,
+ )
+
+ assert actual == candidate
+ request = fake.completions.requests[0]
+ assert request["model"] == "deepseek-test"
+ assert request["temperature"] == 0
+ assert request["max_tokens"] == 32000
+ assert request["response_format"] == {"type": "json_object"}
+ assert request["extra_body"] == {"thinking": {"type": "enabled"}}
+ assert metadata["finish_reason"] == "stop"
+ system_message = request["messages"][0]["content"]
+ assert "must not return or modify execution facts" in system_message
+ assert (
+ "exactly one entry for every supplied input, parameter, and output fact_id"
+ in system_message
+ )
+ assert "no omissions, duplicates, or extras" in system_message
+ assert "final means" in system_message
+ assert "intermediate means" in system_message
+ assert "unknown only" in system_message
+ payload = json.loads(request["messages"][1]["content"])
+ assert payload["execution_facts"] == facts
+ assert "facts_sha256" not in payload["required_candidate_shape"]
+ assert "generated_input" in payload["required_candidate_shape"]["inputs"][0][
+ "replaceability"
+ ]
+ assert payload["execution_facts"]["outputs"]["stdout"]["preview"] == (
+ "baseline complete"
+ )
+
+ previews = {item["path"]: item for item in payload["raw_code_preview"]["files"]}
+ assert previews["run.sh"]["kind"] == "text"
+ assert "content" in previews["run.sh"]
+ assert previews["large_helper.py"]["truncated"] is True
+ assert previews["compiled.bin"]["kind"] == "binary"
+ assert "content" not in previews["compiled.bin"]
+ assert "large_helper.py" in payload["raw_code_preview"]["truncated_files"]
+
+ assert metadata["model"] == "deepseek-test"
+ assert metadata["base_url"] == "https://api.example.test/v1"
+ assert metadata["api_key_alias"] == "NEW_API_KEY"
+ assert metadata["request_id"] == "request-123"
+ assert metadata["attempt"] == 3
+ assert metadata["usage"]["total_tokens"] == 200
+ assert "secret-test-key" not in json.dumps(request)
+ assert "secret-test-key" not in json.dumps(metadata)
+
+
+def test_api_cannot_override_system_bound_facts_digest(tmp_path: Path) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ api_candidate = _candidate(facts)
+ api_candidate["facts_sha256"] = "invented-digest"
+ fake = _FakeClient(response=_response(json.dumps(api_candidate)))
+
+ actual, metadata = module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert actual["facts_sha256"] == facts["facts_sha256"]
+ assert metadata["discarded_fact_fields"] == ["facts_sha256"]
+
+
+def test_non_json_response_is_reported_separately(tmp_path: Path) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ fake = _FakeClient(response=_response("not json"))
+
+ with pytest.raises(module.DeepSeekSemanticError) as raised:
+ module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert raised.value.code == "invalid_json"
+ assert raised.value.metadata["raw_response"] == "not json"
+
+
+def test_missing_input_replaceability_is_bound_from_locked_input_source(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ api_candidate = _candidate(facts)
+ api_candidate.pop("facts_sha256")
+ for item in api_candidate["inputs"]:
+ item.pop("replaceability")
+ fake = _FakeClient(response=_response(json.dumps(api_candidate)))
+
+ actual, metadata = module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert {item["replaceability"] for item in actual["inputs"]} == {
+ "replaceable"
+ }
+ assert {
+ item["action"] for item in metadata["fact_derived_semantic_bindings"]
+ } == {"bind_missing_input_replaceability"}
+
+
+def test_semantic_gate_error_is_not_reported_as_api_error(tmp_path: Path) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ invalid = _candidate(facts)
+ invalid["original_command"] = "invented command"
+ fake = _FakeClient(response=_response(json.dumps(invalid)))
+
+ with pytest.raises(module.DeepSeekSemanticError) as raised:
+ module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert raised.value.code == "semantic_candidate_invalid"
+ assert raised.value.metadata["system_bound_candidate"]["facts_sha256"] == facts[
+ "facts_sha256"
+ ]
+ assert any(
+ error["field"] == "original_command"
+ for error in raised.value.metadata["validation_errors"]
+ )
+
+
+def test_unknown_model_reference_is_discarded_before_field_repair(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ invalid = _candidate(facts)
+ wrong_fact_id = invalid["parameters"][0]["fact_id"]
+ invalid["outputs"][0]["fact_id"] = wrong_fact_id
+ fake = _FakeClient(response=_response(json.dumps(invalid)))
+
+ with pytest.raises(module.DeepSeekSemanticError) as raised:
+ module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert raised.value.code == "semantic_candidate_invalid"
+ assert wrong_fact_id not in {
+ item["fact_id"]
+ for item in raised.value.metadata["system_bound_candidate"]["outputs"]
+ }
+ assert raised.value.metadata["fact_derived_semantic_bindings"] == [
+ {
+ "action": "discard_unknown_reference",
+ "section": "outputs",
+ "fact_id": wrong_fact_id,
+ }
+ ]
+ assert all(
+ error["code"] == "missing_fact_id"
+ for error in raised.value.metadata["validation_errors"]
+ )
+
+
+def test_api_error_is_redacted_and_classified(tmp_path: Path) -> None:
+ module = _module()
+ facts, raw_code = _facts_and_code(tmp_path)
+ fake = _FakeClient(error=RuntimeError("rejected secret-test-key"))
+
+ with pytest.raises(module.DeepSeekSemanticError) as raised:
+ module.generate_semantic_candidate(
+ execution_facts=facts,
+ raw_code=raw_code,
+ environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
+ client_factory=lambda config: fake,
+ )
+
+ assert raised.value.code == "api_request_failed"
+ assert "secret-test-key" not in str(raised.value)
+ assert raised.value.metadata["api_key_alias"] == "NEW_API_KEY"
diff --git a/tests/unit/test_extension_stage_lightweight_evidence.py b/tests/unit/test_extension_stage_lightweight_evidence.py
new file mode 100644
index 000000000..09af3cc37
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_evidence.py
@@ -0,0 +1,99 @@
+from pathlib import Path
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.evidence import (
+ run_raw_baseline,
+ stage_package,
+)
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def test_stage_package_copies_code_and_symlinks_data(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ work = tmp_path / "run" / "work"
+ source_code = _write(raw_code, "scripts/run.sh", "wc -l input.fa\n")
+ source_data = _write(raw_data, "input.fa", ">a\nAAAA\n")
+
+ staged = stage_package(raw_code=raw_code, raw_data=raw_data, work_dir=work)
+
+ assert staged["code_files"] == ["scripts/run.sh"]
+ assert staged["data_files"] == ["input.fa"]
+ assert (work / "scripts/run.sh").read_text(encoding="utf-8") == source_code.read_text(
+ encoding="utf-8"
+ )
+ assert not (work / "scripts/run.sh").is_symlink()
+ assert (work / "input.fa").is_symlink()
+ assert (work / "input.fa").resolve() == source_data.resolve()
+
+
+def test_stage_package_rejects_same_relative_path(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "shared.txt", "code\n")
+ _write(raw_data, "shared.txt", "data\n")
+
+ with pytest.raises(ValueError, match=r"shared\.txt"):
+ stage_package(
+ raw_code=raw_code,
+ raw_data=raw_data,
+ work_dir=tmp_path / "run" / "work",
+ )
+
+
+def test_run_raw_baseline_records_new_and_modified_files(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "printf 'result\\n' > result.txt\nprintf 'changed\\n' > existing.txt\nprintf 'done\\n'\n",
+ )
+ _write(raw_code, "existing.txt", "before\n")
+ _write(raw_data, "input.fa", ">a\nAAAA\n")
+
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+
+ assert evidence["command"] == "bash run.sh"
+ assert evidence["returncode"] == 0
+ assert evidence["success"] is True
+ assert evidence["outputs"]["new_files"] == ["result.txt"]
+ assert evidence["outputs"]["modified_files"] == ["existing.txt"]
+ assert evidence["outputs"]["stdout"]["present"] is True
+ assert evidence["outputs"]["stdout"]["preview"] == "done"
+ assert Path(evidence["logs"]["stdout"]).read_text(encoding="utf-8") == "done\n"
+ assert Path(evidence["logs"]["stderr"]).is_file()
+ assert evidence["before_files"]["input.fa"]["kind"] == "symlink"
+ assert evidence["after_files"]["result.txt"]["size"] > 0
+
+
+def test_stdout_only_success_is_a_valid_output(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "count.sh", "grep -c '^>' input.fa\n")
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+
+ evidence = run_raw_baseline(
+ command="bash count.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+
+ assert evidence["success"] is True
+ assert evidence["outputs"]["new_files"] == []
+ assert evidence["outputs"]["modified_files"] == []
+ assert evidence["outputs"]["stdout"]["present"] is True
+ assert evidence["outputs"]["stdout"]["preview"] == "2"
diff --git a/tests/unit/test_extension_stage_lightweight_fact_contract.py b/tests/unit/test_extension_stage_lightweight_fact_contract.py
new file mode 100644
index 000000000..aa916b981
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_fact_contract.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from pathlib import Path
+
+from gfa_core.extension_stage_lightweight import converter
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _two_step_evidence(tmp_path: Path) -> dict:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "cp model.hmm intermediate.domtblout\n"
+ "python parse.py intermediate.domtblout > parsed.tsv\n",
+ )
+ _write(
+ raw_code,
+ "parse.py",
+ "from pathlib import Path\n"
+ "import sys\n"
+ "print(Path(sys.argv[1]).read_text(encoding='utf-8').strip())\n",
+ )
+ _write(raw_data, "model.hmm", "MODEL\n")
+ return run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+
+
+def test_build_execution_facts_locks_package_and_assigns_stable_ids(
+ tmp_path: Path,
+) -> None:
+ evidence = _two_step_evidence(tmp_path)
+ original = deepcopy(evidence)
+
+ facts = converter.build_execution_facts(evidence)
+
+ assert evidence == original
+ assert facts["original_command"] == "bash run.sh"
+ assert facts["package"]["code_files"] == ["parse.py", "run.sh"]
+ assert facts["package"]["data_files"] == ["model.hmm"]
+ assert facts["package"]["inventory"]["run.sh"]["source"] == "raw_code"
+ assert facts["package"]["inventory"]["model.hmm"]["source"] == "raw_test_data"
+ assert len(facts["package"]["inventory"]["model.hmm"]["sha256"]) == 64
+
+ first, second = facts["commands"]
+ assert first["step_id"] == "step_001"
+ assert second["step_id"] == "step_002"
+ assert first["inputs"][0]["fact_id"] == "step_001:input:001"
+ assert first["parameters"][0]["fact_id"] == "step_001:parameter:002"
+ assert second["inputs"][0]["source"] == "generated_input"
+ assert second["inputs"][0]["path"] == "intermediate.domtblout"
+ assert second["inputs"][0]["fact_id"] == "step_002:input:002"
+
+ outputs = {item["path"]: item for item in facts["outputs"]["files"]}
+ assert outputs["intermediate.domtblout"]["producer_step_ids"] == ["step_001"]
+ assert outputs["parsed.tsv"]["producer_step_ids"] == ["step_002"]
+ assert outputs["intermediate.domtblout"]["fact_id"] == "output:file:001"
+ assert outputs["parsed.tsv"]["fact_id"] == "output:file:002"
+ assert len(facts["facts_sha256"]) == 64
+
+
+def test_absent_stdout_is_not_exposed_as_a_semantic_fact(tmp_path: Path) -> None:
+ facts = converter.build_execution_facts(_two_step_evidence(tmp_path))
+
+ assert facts["outputs"]["stdout"]["present"] is False
+ assert "fact_id" not in facts["outputs"]["stdout"]
+
+
+def test_build_execution_facts_is_deterministic_for_same_evidence(
+ tmp_path: Path,
+) -> None:
+ evidence = _two_step_evidence(tmp_path)
+
+ first = converter.build_execution_facts(evidence)
+ second = converter.build_execution_facts(evidence)
+
+ assert first == second
+ assert first["facts_sha256"] == second["facts_sha256"]
+
+
+def test_build_execution_facts_keeps_unmatched_outputs_without_guessing(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "run.sh", "python make_output.py\n")
+ _write(
+ raw_code,
+ "make_output.py",
+ "from pathlib import Path\n"
+ "Path('dynamically_named.txt').write_text('result\\n', encoding='utf-8')\n",
+ )
+ raw_data.mkdir()
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+
+ facts = converter.build_execution_facts(evidence)
+
+ assert facts["outputs"]["files"] == [
+ {
+ "fact_id": "output:file:001",
+ "path": "dynamically_named.txt",
+ "producer_step_ids": [],
+ }
+ ]
+ assert facts["unmatched_output_fact_ids"] == ["output:file:001"]
diff --git a/tests/unit/test_extension_stage_lightweight_frontend_contract.py b/tests/unit/test_extension_stage_lightweight_frontend_contract.py
new file mode 100644
index 000000000..9f44a6fed
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_frontend_contract.py
@@ -0,0 +1,250 @@
+from pathlib import Path
+import subprocess
+
+
+ROOT = Path(__file__).resolve().parents[2]
+FRONTEND = ROOT / "apps" / "web" / "vue3" / "index.html"
+
+
+def _source() -> str:
+ return FRONTEND.read_text(encoding="utf-8")
+
+
+def test_lightweight_extension_frontend_uses_new_api_and_conversation_cards() -> None:
+ source = _source()
+
+ assert "/api/lightweight-extension-stages/upload" in source
+ assert "lightweightExtensionConversation" in source
+ assert "拓展分析候选核对" in source
+ assert "提出修订意见" in source
+ assert "授权发布" in source
+
+
+def test_lightweight_extension_frontend_deduplicates_operations_and_cards() -> None:
+ source = _source()
+
+ assert "lightweightOperationPromises" in source
+ assert "lightweightConversationCardKeys" in source
+ assert "operation_token" in source
+ assert "card_id" in source
+
+
+def test_lightweight_published_run_button_does_not_pass_pointer_event_as_extension_id() -> None:
+ source = _source()
+
+ assert '@click="openLightweightRuntimeCard()"' in source
+
+
+def test_lightweight_extension_frontend_keeps_running_controls_and_stops_lightweight_operation() -> None:
+ source = _source()
+
+ assert "isExecuting.value = true" in source
+ assert "Polling" in source
+ assert "⏹ Stop" in source
+ assert "/operations/${encodeURIComponent(operationToken)}/stop" in source
+ assert "setInterval" in source
+
+
+def test_lightweight_extension_frontend_does_not_route_new_submission_through_legacy_gates() -> None:
+ source = _source()
+
+ start = source.index("const buildLightweightExtensionStage")
+ end = source.index("const buildExtensionStage", start)
+ lightweight = source[start:end]
+
+ assert "/api/extension-stages/build" not in lightweight
+ assert "nl-runtime-gate" not in lightweight
+ assert "frontend-e2e-gate" not in lightweight
+
+
+def test_lightweight_extension_frontend_accepts_command_or_code_with_required_data() -> None:
+ source = _source()
+ start = source.index("const buildLightweightExtensionStage")
+ end = source.index("const confirmLightweightCandidate", start)
+ upload = source[start:end]
+
+ assert "const hasCommand = Boolean(String(extensionBuild.command || '').trim());" in upload
+ assert "const hasCode = extensionBuild.codeFiles.length > 0;" in upload
+ assert "(!extensionBuild.dataFiles.length && !hasDataPaths) || (!hasCommand && !hasCode)" in upload
+ assert "请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。" in upload
+ assert "if (hasCommand) form.append('original_command', extensionBuild.command);" in upload
+ assert "请填写名称和原始命令,并上传原始代码与测试数据。" not in upload
+
+
+def test_lightweight_extension_api_errors_render_structured_details_in_node() -> None:
+ source = _source()
+ start = source.index("const extensionApiError")
+ end = source.index("const extensionApiUrl", start)
+ script = f"""
+const assert = require('assert');
+{source[start:end]}
+(async () => {{
+ const objectMessage = await extensionApiError({{
+ status: 400,
+ statusText: 'Bad Request',
+ json: async () => ({{detail: {{phase: 'upload', message: '缺少执行入口'}}}})
+ }}, '提交失败');
+ assert.strictEqual(objectMessage, '提交失败:缺少执行入口');
+
+ const validationMessage = await extensionApiError({{
+ status: 422,
+ statusText: 'Unprocessable Entity',
+ json: async () => ({{detail: [{{loc: ['body', 'raw_data_files'], msg: 'Field required'}}]}})
+ }}, '提交失败');
+ assert.strictEqual(validationMessage, '提交失败:Field required');
+}})().catch((error) => {{ console.error(error); process.exit(1); }});
+"""
+ result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+
+
+def test_lightweight_upload_card_uses_command_and_code_neutral_entry_wording() -> None:
+ source = _source()
+ start = source.index("const showLightweightCandidate")
+ end = source.index("const confirmLightweightCandidate", start)
+ upload = source[start:end]
+
+ assert "执行入口已在隔离环境运行" in upload
+ assert "系统正在确定执行入口,并运行上传的代码和测试数据" in upload
+ assert "✓ 执行入口已成功复现;候选信息已生成。" in upload
+ assert "原始命令已在隔离环境执行" not in upload
+ assert "系统正在以原始命令执行上传的代码和测试数据" not in upload
+
+
+def test_lightweight_extension_upload_reports_selection_and_server_acceptance() -> None:
+ source = _source()
+ handlers_start = source.index("const onExtensionCodeFilesChange")
+ handlers_end = source.index("const removeExtensionFile", handlers_start)
+ handlers = source[handlers_start:handlers_end]
+ upload_start = source.index("const buildLightweightExtensionStage")
+ upload_end = source.index("const confirmLightweightCandidate", upload_start)
+ upload = source[upload_start:upload_end]
+
+ assert "已选择 ${selected.length} 个原始代码文件" in handlers
+ assert "已选择 ${selected.length} 个测试数据文件" in handlers
+ assert "文件上传成功,已开始生成并验收" in upload
+ assert upload.index("if (!response.ok)") < upload.index("文件上传成功,已开始生成并验收")
+
+
+def test_lightweight_extension_frontend_accepts_uploaded_or_server_path_data() -> None:
+ source = _source()
+ upload_start = source.index("const buildLightweightExtensionStage")
+ upload_end = source.index("const confirmLightweightCandidate", upload_start)
+ upload = source[upload_start:upload_end]
+
+ assert 'v-model="extensionBuild.dataPaths"' in source
+ assert "每行填写一个绝对路径" in source
+ assert "const hasDataPaths = Boolean(String(extensionBuild.dataPaths || '').trim());" in upload
+ assert "(!extensionBuild.dataFiles.length && !hasDataPaths)" in upload
+ assert "form.append('raw_data_paths', extensionBuild.dataPaths);" in upload
+
+
+def test_lightweight_cards_render_in_main_chat_not_extension_form() -> None:
+ source = _source()
+ form_start = source.index("生成并验收")
+ main_chat = source.index('class="agent-chat-history"')
+ card_renderer = source.index('v-if="lightweightExtensionConversation.cards.length"')
+
+ assert 'lightweightExtensionConversation.cards.length' not in source[form_start:main_chat]
+ assert card_renderer > main_chat
+ assert 'class="role-badge ai"' in source[card_renderer - 500:card_renderer + 500]
+
+
+def test_lightweight_revision_uses_main_chat_input_without_inline_editor() -> None:
+ source = _source()
+ card_start = source.index('v-if="lightweightExtensionConversation.cards.length"')
+ card_end = source.index('', card_start)
+ cards = source[card_start:card_end]
+ handler_start = source.index("const handleNlInput")
+ handler_end = source.index("// V2: RAG模式", handler_start)
+ handler = source[handler_start:handler_end]
+
+ assert 'v-model="card.feedback"' not in cards
+ assert 'v-model="card.revision_target"' not in cards
+ assert '@click="beginLightweightRevision(card)"' in cards
+ assert "pendingRevisionCardId" in source
+ assert "revision_target: 'semantic'" in source
+ assert "reviseLightweightCandidate(revisionCard, userInput)" in handler
+
+
+def test_semantic_candidate_failure_offers_semantic_only_retry() -> None:
+ source = _source()
+
+ assert "retry_semantic" in source
+ assert "retryLightweightSemantic(card)" in source
+ assert "/retry-semantic" in source
+ assert "只重新生成说明,不会重新运行原始命令" in source
+
+
+def test_lightweight_card_and_operation_dedup_helpers_execute_in_node() -> None:
+ source = _source()
+ card_start = source.index("const lightweightCardKey")
+ card_end = source.index("const finishLightweightCard", card_start)
+ poll_start = source.index("const pollLightweightOperation")
+ poll_end = source.index("const submitLightweightOperation", poll_start)
+ script = f"""
+const assert = require('assert');
+const reactive = (value) => value;
+const lightweightExtensionConversation = {{ cards: [] }};
+const lightweightConversationCardKeys = new Set();
+{source[card_start:card_end]}
+const first = addLightweightCard({{ card_id: 'candidate', status: 'pending' }});
+const duplicate = addLightweightCard({{ card_id: 'candidate', status: 'pending' }});
+assert(first);
+assert.strictEqual(duplicate, null);
+assert.strictEqual(lightweightExtensionConversation.cards.length, 1);
+
+const lightweightOperationPromises = new Map();
+const extensionApiFetch = async () => ({{ ok: true, json: async () => ({{ state: 'completed', status: 'confirmed' }}) }});
+const setLightweightRunning = () => {{}};
+const clearLightweightRunning = () => {{}};
+const forgetLightweightOperation = () => {{}};
+const window = {{ setTimeout: (callback) => setTimeout(callback, 0) }};
+{source[poll_start:poll_end]}
+(async () => {{
+ const card = {{ loading: true }};
+ let completed = 0;
+ const one = pollLightweightOperation('demo', 'same-token', card, async () => {{ completed += 1; }});
+ const two = pollLightweightOperation('demo', 'same-token', card, async () => {{ completed += 1; }});
+ assert.strictEqual(one, two);
+ await one;
+ assert.strictEqual(completed, 1);
+ assert.strictEqual(lightweightOperationPromises.size, 0);
+}})().catch((error) => {{ console.error(error); process.exit(1); }});
+"""
+ result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+
+
+def test_lightweight_frontend_phase_b_closure_helpers_execute_in_one_node_script() -> None:
+ source = _source()
+ helper_start = source.index("const isLightweightExtensionId")
+ helper_end = source.index("const buildLightweightExtensionStage", helper_start)
+ script = f"""
+const assert = require('assert');
+const lightweightExtensions = {{ value: [{{ extension_id: 'lightweight_tree', status: 'published' }}] }};
+{source[helper_start:helper_end]}
+assert.strictEqual(isLightweightExtensionId('lightweight_tree'), true);
+assert.strictEqual(isLightweightExtensionId('legacy_tree'), false);
+const runs = [{{ run_id: 'run_old' }}, {{ run_id: 'run_new' }}];
+assert.deepStrictEqual(lightweightRunById(runs, 'run_old'), {{ run_id: 'run_old' }});
+assert.strictEqual(lightweightResumeNext('upload'), 'candidate');
+assert.strictEqual(lightweightResumeNext('confirm'), 'validate');
+assert.strictEqual(lightweightResumeNext('revise'), 'candidate');
+assert.strictEqual(lightweightResumeNext('validate'), 'review');
+assert.strictEqual(lightweightResumeNext('review'), 'publish');
+assert.strictEqual(lightweightResumeNext('publish'), 'list');
+assert.strictEqual(lightweightResumeNext('run'), 'runtime_result');
+assert.strictEqual(lightweightCandidateConfirmationSummary({{ revision_summary: 'parameters.description' }}), '✓ 已确认候选|已修订:parameters.description');
+assert.strictEqual(lightweightCandidateConfirmationSummary({{}}), '✓ 已确认候选|未作修订');
+const rows = lightweightResultRows({{ execution_command: 'bash run.sh', projection: {{ files: [], stdout: {{ present: true, text: '42\\n' }} }}, logs: {{ stdout: 'stdout.log' }} }});
+assert.strictEqual(rows.find((row) => row.item === '标准输出').value, '42\\n');
+assert.strictEqual(lightweightParameterText({{ flag: '--cut_tc', value: 'true', fact_id: 'p1' }}), '--cut_tc = true');
+const parameterRows = lightweightRows({{ inputs: [], parameters: [{{ flag: '--cut_tc', value: 'true', fact_id: 'p1' }}], outputs: {{ stdout: {{ present: false }} }} }});
+assert.strictEqual(parameterRows.find((row) => row.item === '可改参数').value, '--cut_tc = true');
+"""
+ result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+ intent = source[source.index("const runExtensionStageFromIntent"):source.index("const extensionOutputIcon")]
+ assert "if (isLightweightExtensionId(extensionId))" in intent
+ assert intent.index("openLightweightRuntimeCard(extensionId)") < intent.index("executeExtensionStageRun(")
diff --git a/tests/unit/test_extension_stage_lightweight_process_control.py b/tests/unit/test_extension_stage_lightweight_process_control.py
new file mode 100644
index 000000000..28af8632f
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_process_control.py
@@ -0,0 +1,583 @@
+from __future__ import annotations
+
+import os
+import hashlib
+import json
+from pathlib import Path
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+import gfa_core.extension_stage_lightweight.process_control as process_module
+import pytest
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+from gfa_core.extension_stage_lightweight.process_control import (
+ start_managed_process,
+ stop_process_group,
+)
+from gfa_core.extension_stage_lightweight.runtime_binding import (
+ render_runtime_package,
+ replay_runtime_binding,
+ resolve_runtime_bindings,
+)
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _wait_for(path: Path) -> None:
+ deadline = time.monotonic() + 5
+ while not path.is_file() and time.monotonic() < deadline:
+ time.sleep(0.01)
+ assert path.is_file()
+
+
+def _pid_is_running(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ return True
+
+
+def _pid_start_time(pid: int) -> int:
+ fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()
+ return int(fields[21])
+
+
+def _force_stop(identity: dict[str, int]) -> None:
+ if identity:
+ try:
+ os.killpg(identity["pgid"], 9)
+ except ProcessLookupError:
+ pass
+
+
+def _term_ignoring_child_command(child_pid_path: str, child_ready_path: str) -> str:
+ return (
+ f"{sys.executable} -c \"import signal, time; "
+ "signal.signal(signal.SIGTERM, signal.SIG_IGN); "
+ f"open('{child_ready_path}', 'w').write('ready'); time.sleep(30)\" "
+ f"& child=$!; echo $child > {child_pid_path}; "
+ "trap 'exit 0' TERM; while :; do sleep 1; done\n"
+ )
+
+
+def _digest(value: object) -> str:
+ return hashlib.sha256(
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ ).hexdigest()
+
+
+def _render_sleeping_package(tmp_path: Path) -> dict[str, object]:
+ raw_code = tmp_path / "raw_code"
+ script = _write(
+ raw_code,
+ "run.sh",
+ _term_ignoring_child_command("child.pid", "child.ready"),
+ )
+ script_hash = hashlib.sha256(script.read_bytes()).hexdigest()
+ contract: dict[str, object] = {
+ "schema_version": "lightweight-runtime-binding/v1",
+ "facts_sha256": "facts-locked",
+ "original_command": "bash run.sh\ntrue",
+ "source_scripts": [{"path": "run.sh", "sha256": script_hash, "locked": True}],
+ "package_files": [
+ {
+ "path": "run.sh",
+ "source": "raw_code",
+ "kind": "file",
+ "size": script.stat().st_size,
+ "sha256": script_hash,
+ "locked": True,
+ "exposed_as_input": False,
+ }
+ ],
+ "inputs": [],
+ "parameters": [],
+ "path_references": [],
+ "outputs": {"files": [], "stdout": {"present": True}},
+ "patch_targets": [],
+ "unpatchable_targets": [],
+ }
+ contract["package_digest"] = _digest(
+ {
+ "run.sh": {
+ "kind": "file",
+ "size": script.stat().st_size,
+ "sha256": script_hash,
+ "source": "raw_code",
+ }
+ }
+ )
+ contract["contract_digest"] = _digest(contract)
+ resolved = resolve_runtime_bindings(contract, {}, {})
+ assert resolved["status"] == "resolved"
+ return render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+
+def test_baseline_cancellation_stops_its_process_group_only(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ child_pid_path = tmp_path / "child.pid"
+ child_ready_path = tmp_path / "child.ready"
+ _write(
+ raw_code,
+ "run.sh",
+ _term_ignoring_child_command(str(child_pid_path), str(child_ready_path)),
+ )
+ _write(raw_data, "input.txt", "input\n")
+
+ started = threading.Event()
+ identity: dict[str, int] = {}
+ result: dict[str, object] = {}
+
+ def run() -> None:
+ result["evidence"] = run_raw_baseline(
+ command="bash run.sh\ntrue",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ on_process_start=lambda value: (identity.update(value), started.set()),
+ )
+
+ thread = threading.Thread(target=run)
+ unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
+ try:
+ thread.start()
+ assert started.wait(timeout=5)
+ _wait_for(child_pid_path)
+ _wait_for(child_ready_path)
+ child_pid = int(child_pid_path.read_text(encoding="utf-8").strip())
+
+ assert identity["pid"] == identity["pgid"]
+ assert identity["pid_start_time"] > 0
+ assert stop_process_group(identity) is True
+
+ thread.join(timeout=5)
+ assert not thread.is_alive()
+ assert result["evidence"]["process"] == identity
+ assert not _pid_is_running(child_pid)
+ assert unrelated.poll() is None
+ finally:
+ if thread.is_alive():
+ _force_stop(identity)
+ thread.join(timeout=5)
+ unrelated.terminate()
+ unrelated.wait(timeout=5)
+
+
+def test_stop_rejects_a_process_group_without_the_original_leader_identity() -> None:
+ unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
+ try:
+ assert stop_process_group(
+ {
+ "pid": unrelated.pid,
+ "pgid": unrelated.pid,
+ "pid_start_time": 0,
+ }
+ ) is False
+ assert unrelated.poll() is None
+ finally:
+ unrelated.terminate()
+ unrelated.wait(timeout=5)
+
+
+def test_managed_supervisor_anchors_group_and_waits_for_descendants(
+ tmp_path: Path,
+) -> None:
+ command_pid_path = tmp_path / "command.pid"
+ release_path = tmp_path / "release"
+ command = (
+ f"echo $$ > {command_pid_path}; "
+ f"while [ ! -f {release_path} ]; do sleep 0.01; done; "
+ "sleep 0.2 & exit 7"
+ )
+ process, identity = start_managed_process(["bash", "-c", command])
+ try:
+ _wait_for(command_pid_path)
+ command_pid = int(command_pid_path.read_text(encoding="utf-8").strip())
+ assert identity["pid"] != command_pid
+ assert isinstance(identity["supervisor_token"], str)
+
+ started = time.monotonic()
+ release_path.write_text("release\n", encoding="utf-8")
+ assert process.wait(timeout=5) == 7
+ assert time.monotonic() - started >= 0.15
+ finally:
+ release_path.touch()
+ if process.poll() is None:
+ _force_stop(identity)
+ process.wait(timeout=5)
+
+
+def test_stop_rejects_valid_numeric_identity_without_supervisor_token() -> None:
+ unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
+ try:
+ assert stop_process_group(
+ {
+ "pid": unrelated.pid,
+ "pgid": unrelated.pid,
+ "pid_start_time": _pid_start_time(unrelated.pid),
+ "supervisor_token": "stale-token",
+ }
+ ) is False
+ assert unrelated.poll() is None
+ finally:
+ if unrelated.poll() is None:
+ unrelated.terminate()
+ unrelated.wait(timeout=5)
+
+
+def test_start_retries_transient_proc_disappearance(
+ monkeypatch,
+) -> None:
+ original_read_text = Path.read_text
+ missing_once = True
+
+ def flaky_read_text(path: Path, *args, **kwargs) -> str:
+ nonlocal missing_once
+ if missing_once and path.parent.parent == Path("/proc") and path.name == "stat":
+ missing_once = False
+ raise FileNotFoundError(path)
+ return original_read_text(path, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "read_text", flaky_read_text)
+ process, identity = start_managed_process(["bash", "-c", "sleep 0.1; exit 0"])
+
+ assert identity["pid"] == identity["pgid"]
+ assert process.wait(timeout=5) == 0
+
+
+def test_supervised_command_keeps_default_sigterm_behavior() -> None:
+ process, identity = start_managed_process(["sleep", "30"])
+
+ assert stop_process_group(identity) is True
+ assert process.wait(timeout=5) == -signal.SIGTERM
+
+
+def test_supervisor_mirrors_independent_command_sigkill(tmp_path: Path) -> None:
+ command_pid_path = tmp_path / "command.pid"
+ command = (
+ "import os, time; "
+ f"open('{command_pid_path}', 'w').write(str(os.getpid())); "
+ "time.sleep(30)"
+ )
+ process, _identity = start_managed_process([sys.executable, "-c", command])
+ _wait_for(command_pid_path)
+ command_pid = int(command_pid_path.read_text(encoding="utf-8"))
+
+ os.kill(command_pid, signal.SIGKILL)
+
+ assert process.wait(timeout=5) == -signal.SIGKILL
+
+
+def test_stop_fails_closed_if_supervisor_disappears_during_grace(
+ tmp_path: Path,
+) -> None:
+ child_pid_path = tmp_path / "child.pid"
+ child_ready_path = tmp_path / "child.ready"
+ command = _term_ignoring_child_command(
+ str(child_pid_path), str(child_ready_path)
+ )
+ process, identity = start_managed_process(["bash", "-c", command])
+ _wait_for(child_pid_path)
+ _wait_for(child_ready_path)
+ child_pid = int(child_pid_path.read_text(encoding="utf-8"))
+ result: dict[str, bool] = {}
+
+ thread = threading.Thread(
+ target=lambda: result.update(
+ stopped=stop_process_group(identity, grace_seconds=0.5)
+ )
+ )
+ try:
+ thread.start()
+ time.sleep(0.05)
+ os.kill(int(identity["pid"]), signal.SIGKILL)
+ thread.join(timeout=5)
+
+ assert not thread.is_alive()
+ assert result["stopped"] is False
+ assert _pid_is_running(child_pid)
+ finally:
+ if _pid_is_running(child_pid):
+ os.kill(child_pid, signal.SIGKILL)
+ if process.poll() is None:
+ os.kill(int(identity["pid"]), signal.SIGKILL)
+ process.wait(timeout=5)
+
+
+def test_supervisor_state_rechecks_a_clean_zombie_transition(monkeypatch) -> None:
+ snapshots = iter(
+ [
+ ("S", 101, 101, 202),
+ ("Z", 101, 101, 202),
+ ]
+ )
+ monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: next(snapshots))
+ monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: False)
+ monkeypatch.setattr(process_module, "_group_membership", lambda *_args: "empty")
+
+ assert process_module._supervisor_state(
+ pid=101,
+ pgid=101,
+ start_time=202,
+ token="token",
+ ) == "exited"
+
+
+def test_stop_fails_closed_if_sigkill_target_disappears(monkeypatch) -> None:
+ identity = {
+ "pid": 101,
+ "pgid": 101,
+ "pid_start_time": 202,
+ "supervisor_token": "token",
+ }
+ monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: True)
+ monkeypatch.setattr(
+ process_module, "_wait_for_supervisor_state", lambda **_kwargs: "running"
+ )
+
+ def killpg(_pgid: int, signum: int) -> None:
+ if signum == signal.SIGKILL:
+ raise ProcessLookupError
+
+ monkeypatch.setattr(process_module.os, "killpg", killpg)
+
+ assert stop_process_group(identity) is False
+
+
+def test_stop_requires_verified_exit_after_sigkill(monkeypatch) -> None:
+ identity = {
+ "pid": 101,
+ "pgid": 101,
+ "pid_start_time": 202,
+ "supervisor_token": "token",
+ }
+ states = iter(["running", "lost"])
+ monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: True)
+ monkeypatch.setattr(
+ process_module,
+ "_wait_for_supervisor_state",
+ lambda **_kwargs: next(states),
+ )
+ monkeypatch.setattr(process_module.os, "killpg", lambda *_args: None)
+
+ assert stop_process_group(identity) is False
+
+
+def test_start_accepts_only_the_execution_context_used_by_callers(
+ tmp_path: Path,
+) -> None:
+ process_env = os.environ.copy()
+ process_env["BBAV5_PROCESS_TEST"] = "allowed"
+ process, _identity = start_managed_process(
+ ["bash", "-c", "printf '%s\\n' \"$BBAV5_PROCESS_TEST\"; pwd; cat"],
+ cwd=tmp_path,
+ env=process_env,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ stdout, stderr = process.communicate(b"payload\n", timeout=5)
+
+ assert process.returncode == 0
+ assert stdout.decode().splitlines() == ["allowed", str(tmp_path), "payload"]
+ assert stderr == b""
+
+
+@pytest.mark.parametrize(
+ ("group_membership", "expected"),
+ [("empty", "exited"), ("member", "matching"), ("uncertain", "matching")],
+)
+def test_post_kill_absent_anchor_requires_an_empty_original_group(
+ monkeypatch,
+ group_membership: str,
+ expected: str,
+) -> None:
+ monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
+ monkeypatch.setattr(
+ process_module.os,
+ "kill",
+ lambda *_args: (_ for _ in ()).throw(ProcessLookupError()),
+ )
+ monkeypatch.setattr(
+ process_module,
+ "_group_membership",
+ lambda *_args: group_membership,
+ )
+
+ assert process_module._supervisor_state(
+ pid=101,
+ pgid=101,
+ start_time=202,
+ token="token",
+ verify_absent_exit=True,
+ ) == expected
+
+
+def test_post_kill_present_zombie_is_not_verified_absent(monkeypatch) -> None:
+ monkeypatch.setattr(
+ process_module,
+ "_read_proc_stat",
+ lambda _pid: ("Z", 101, 101, 202),
+ )
+ monkeypatch.setattr(
+ process_module, "_group_membership", lambda *_args: "empty"
+ )
+
+ assert process_module._supervisor_state(
+ pid=101,
+ pgid=101,
+ start_time=202,
+ token="token",
+ verify_absent_exit=True,
+ ) == "matching"
+
+
+def test_unreadable_group_member_fails_closed(monkeypatch) -> None:
+ monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
+ monkeypatch.setattr(Path, "exists", lambda _path: True)
+ monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
+
+ assert process_module._group_membership(101, 202) == "uncertain"
+
+
+def test_disappeared_unreadable_group_entry_is_skipped(monkeypatch) -> None:
+ monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
+ monkeypatch.setattr(Path, "exists", lambda _path: False)
+ monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
+
+ assert process_module._group_membership(101, 202) == "empty"
+
+
+def test_stop_fails_closed_on_proc_stat_permission_error(monkeypatch) -> None:
+ monkeypatch.setattr(
+ Path,
+ "read_text",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError()),
+ )
+
+ assert stop_process_group(
+ {
+ "pid": 101,
+ "pgid": 101,
+ "pid_start_time": 202,
+ "supervisor_token": "token",
+ }
+ ) is False
+
+
+def test_stop_fails_closed_on_proc_cmdline_oserror(monkeypatch) -> None:
+ monkeypatch.setattr(
+ process_module,
+ "_read_proc_stat",
+ lambda _pid: ("S", 101, 101, 202),
+ )
+ monkeypatch.setattr(
+ Path,
+ "read_bytes",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("unreadable")),
+ )
+
+ assert stop_process_group(
+ {
+ "pid": 101,
+ "pgid": 101,
+ "pid_start_time": 202,
+ "supervisor_token": "token",
+ }
+ ) is False
+
+
+def test_proc_iteration_permission_error_is_uncertain(monkeypatch) -> None:
+ monkeypatch.setattr(
+ Path,
+ "iterdir",
+ lambda _path: (_ for _ in ()).throw(PermissionError()),
+ )
+
+ assert process_module._group_membership(101, 202) == "uncertain"
+
+
+def test_proc_entry_exists_oserror_is_uncertain(monkeypatch) -> None:
+ monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
+ monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
+ monkeypatch.setattr(
+ Path,
+ "exists",
+ lambda _path: (_ for _ in ()).throw(OSError("unreadable")),
+ )
+
+ assert process_module._group_membership(101, 202) == "uncertain"
+
+
+def test_start_rejects_unsupported_popen_options_before_launch(
+ monkeypatch,
+) -> None:
+ monkeypatch.setattr(
+ process_module.subprocess,
+ "Popen",
+ lambda *_args, **_kwargs: pytest.fail("unsupported option reached launch"),
+ )
+ unsupported = {
+ "pass_fds": (1,),
+ "shell": True,
+ "executable": "/bin/bash",
+ "preexec_fn": lambda: None,
+ "start_new_session": False,
+ "process_group": 0,
+ "user": 1000,
+ "group": 1000,
+ "extra_groups": [1000],
+ "umask": 0o022,
+ }
+
+ for option, value in unsupported.items():
+ with pytest.raises(TypeError, match=option):
+ start_managed_process(["true"], **{option: value})
+
+
+def test_replay_reports_process_identity_to_its_start_callback(tmp_path: Path) -> None:
+ rendered = _render_sleeping_package(tmp_path)
+ started = threading.Event()
+ identity: dict[str, int] = {}
+ result: dict[str, object] = {}
+
+ def run() -> None:
+ result["value"] = replay_runtime_binding(
+ rendered,
+ on_process_start=lambda value: (identity.update(value), started.set()),
+ )
+
+ thread = threading.Thread(target=run)
+ unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
+ thread.start()
+ assert started.wait(timeout=5)
+ try:
+ child_pid_path = Path(rendered["work_dir"]) / "child.pid"
+ child_ready_path = Path(rendered["work_dir"]) / "child.ready"
+ _wait_for(child_pid_path)
+ _wait_for(child_ready_path)
+ child_pid = int(child_pid_path.read_text(encoding="utf-8").strip())
+ assert identity["pid"] == identity["pgid"]
+ assert stop_process_group(identity) is True
+ thread.join(timeout=5)
+ assert not thread.is_alive()
+ assert result["value"]["process"] == identity
+ assert not _pid_is_running(child_pid)
+ assert unrelated.poll() is None
+ finally:
+ if thread.is_alive():
+ _force_stop(identity)
+ thread.join(timeout=5)
+ unrelated.terminate()
+ unrelated.wait(timeout=5)
diff --git a/tests/unit/test_extension_stage_lightweight_raw_package_normalizer.py b/tests/unit/test_extension_stage_lightweight_raw_package_normalizer.py
new file mode 100644
index 000000000..ccb3ff652
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_raw_package_normalizer.py
@@ -0,0 +1,62 @@
+from pathlib import Path
+
+from gfa_core.extension_stage.raw_package_normalizer import normalize_raw_package
+
+
+def test_single_shell_upload_becomes_entry_command(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ raw_code.mkdir()
+ raw_data.mkdir()
+ script = raw_code / "run.sh"
+ script.write_text("cp input.fa output.fa\n", encoding="utf-8")
+
+ normalized = normalize_raw_package(
+ explicit_command="",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ saved_code=[script],
+ )
+
+ assert normalized["entry_command"] == "bash run.sh"
+ assert normalized["entry_source"] == "single_shell_script"
+ assert normalized["requires_user_confirmation"] is False
+
+
+def test_multiple_shell_uploads_require_entry_confirmation(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ raw_code.mkdir()
+ raw_data.mkdir()
+ (raw_code / "run_a.sh").write_text("echo a\n", encoding="utf-8")
+ (raw_code / "run_b.sh").write_text("echo b\n", encoding="utf-8")
+
+ normalized = normalize_raw_package(
+ explicit_command="",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ saved_code=sorted(raw_code.iterdir()),
+ )
+
+ assert normalized["entry_source"] == "ambiguous_shell_script"
+ assert normalized["requires_user_confirmation"] is True
+
+
+def test_inline_command_is_wrapped_as_run_sh(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ raw_code.mkdir()
+ raw_data.mkdir()
+
+ normalized = normalize_raw_package(
+ explicit_command="mafft --auto input.fa > output.msa.fa",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ saved_code=[],
+ )
+
+ assert normalized["entry_command"] == "bash run.sh"
+ assert normalized["entry_source"] == "inline_command_wrapped"
+ assert (raw_code / "run.sh").read_text(encoding="utf-8").strip().endswith(
+ "mafft --auto input.fa > output.msa.fa"
+ )
diff --git a/tests/unit/test_extension_stage_lightweight_replay.py b/tests/unit/test_extension_stage_lightweight_replay.py
new file mode 100644
index 000000000..aea8c22c9
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_replay.py
@@ -0,0 +1,96 @@
+import json
+from pathlib import Path
+
+from gfa_core.extension_stage_lightweight.converter import (
+ build_spec,
+ replay,
+ write_spec,
+ write_wrapper,
+)
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _baseline(tmp_path: Path) -> tuple[Path, Path, dict[str, object]]:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_data, "input.txt", "baseline\n")
+ evidence = run_raw_baseline(
+ command="cp input.txt output.txt",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ return raw_code, raw_data, evidence
+
+
+def test_build_and_write_spec_is_json_serializable(tmp_path: Path) -> None:
+ _, _, evidence = _baseline(tmp_path)
+
+ spec = build_spec(evidence)
+ destination = write_spec(spec, tmp_path / "lightweight_spec.json")
+
+ loaded = json.loads(destination.read_text(encoding="utf-8"))
+ assert loaded["schema_version"] == "lightweight-extension/v1"
+ assert loaded["original_command"] == "cp input.txt output.txt"
+ assert loaded["baseline"]["returncode"] == 0
+ assert loaded["outputs"]["files"] == ["output.txt"]
+ assert loaded["commands"][0]["inputs"][0]["path"] == "input.txt"
+
+
+def test_wrapper_preserves_original_command(tmp_path: Path) -> None:
+ _, _, evidence = _baseline(tmp_path)
+ spec = build_spec(evidence)
+
+ wrapper = write_wrapper(spec, tmp_path / "run_extension.sh")
+
+ text = wrapper.read_text(encoding="utf-8")
+ assert "cp input.txt output.txt" in text
+ assert wrapper.stat().st_mode & 0o111
+
+
+def test_replay_runs_in_a_new_directory_and_reproduces_output(tmp_path: Path) -> None:
+ raw_code, raw_data, evidence = _baseline(tmp_path)
+ spec = build_spec(evidence)
+
+ result = replay(
+ spec=spec,
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "replay",
+ )
+
+ assert result["success"] is True
+ assert result["contract_passed"] is True
+ assert result["work_dir"] != evidence["work_dir"]
+ assert (tmp_path / "replay" / "work" / "output.txt").read_text(
+ encoding="utf-8"
+ ) == "baseline\n"
+
+
+def test_replay_input_override_is_bound_by_symlink(tmp_path: Path) -> None:
+ raw_code, raw_data, evidence = _baseline(tmp_path)
+ spec = build_spec(evidence)
+ replacement = _write(tmp_path, "replacement/new.txt", "replacement\n")
+
+ result = replay(
+ spec=spec,
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "replay",
+ input_overrides={"input.txt": replacement},
+ )
+
+ rebound = tmp_path / "replay" / "work" / "input.txt"
+ assert result["success"] is True
+ assert rebound.is_symlink()
+ assert rebound.resolve() == replacement.resolve()
+ assert (tmp_path / "replay" / "work" / "output.txt").read_text(
+ encoding="utf-8"
+ ) == "replacement\n"
diff --git a/tests/unit/test_extension_stage_lightweight_runtime_binding.py b/tests/unit/test_extension_stage_lightweight_runtime_binding.py
new file mode 100644
index 000000000..8fa91605b
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_runtime_binding.py
@@ -0,0 +1,467 @@
+from __future__ import annotations
+
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.converter import build_execution_facts
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+from gfa_core.extension_stage_lightweight.runtime_binding import (
+ build_runtime_binding_contract,
+)
+
+
+def test_package_exports_runtime_binding_public_api() -> None:
+ from gfa_core.extension_stage_lightweight import (
+ RuntimeBindingError,
+ build_runtime_binding_contract as package_build_contract,
+ render_runtime_package,
+ replay_runtime_binding,
+ resolve_runtime_bindings,
+ )
+ from gfa_core.extension_stage_lightweight import runtime_binding
+
+ assert RuntimeBindingError is runtime_binding.RuntimeBindingError
+ assert package_build_contract is runtime_binding.build_runtime_binding_contract
+ assert resolve_runtime_bindings is runtime_binding.resolve_runtime_bindings
+ assert render_runtime_package is runtime_binding.render_runtime_package
+ assert replay_runtime_binding is runtime_binding.replay_runtime_binding
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _package(tmp_path: Path) -> tuple[dict, dict, Path]:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "head -n 1 input.fa > intermediate.fa\n"
+ "cp intermediate.fa result.fa\n",
+ )
+ _write(raw_code, "unused_helper.py", "print('locked code')\n")
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ facts = build_execution_facts(evidence)
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Keep the first FASTA record line.",
+ "inputs": [
+ {
+ "fact_id": item["fact_id"],
+ "role": "uploaded_sequences",
+ "description": "User-provided sequence data.",
+ "replaceability": "fixed_resource",
+ }
+ for command in facts["commands"]
+ for item in command["inputs"]
+ ],
+ "parameters": [
+ {
+ "fact_id": item["fact_id"],
+ "display_name": "user-facing label",
+ "description": "A command value.",
+ }
+ for command in facts["commands"]
+ for item in command["parameters"]
+ ],
+ "outputs": [
+ {
+ "fact_id": item["fact_id"],
+ "description": "Observed result file.",
+ "result_role": "final",
+ }
+ for item in facts["outputs"]["files"]
+ ],
+ }
+ return facts, candidate, raw_code
+
+
+def _by_fact_id(items: list[dict]) -> dict[str, dict]:
+ return {item["fact_id"]: item for item in items}
+
+
+def test_contract_locks_code_and_owns_external_and_generated_inputs(
+ tmp_path: Path,
+) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+
+ contract = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ assert contract["schema_version"] == "lightweight-runtime-binding/v1"
+ assert contract["facts_sha256"] == facts["facts_sha256"]
+ assert contract["original_command"] == "bash run.sh"
+
+ inputs = _by_fact_id(contract["inputs"])
+ external = next(
+ item for item in inputs.values() if item["source"] == "baseline_input"
+ )
+ generated = next(
+ item for item in inputs.values() if item["source"] == "generated_input"
+ )
+ assert external["path"] == "input.fa"
+ assert external["replaceable"] is True
+ assert external["locked"] is False
+ assert generated["path"] == "intermediate.fa"
+ assert generated["replaceable"] is False
+ assert generated["locked"] is True
+
+ package_files = {item["path"]: item for item in contract["package_files"]}
+ assert package_files["run.sh"]["locked"] is True
+ assert package_files["unused_helper.py"]["locked"] is True
+ assert package_files["run.sh"]["exposed_as_input"] is False
+ assert package_files["unused_helper.py"]["exposed_as_input"] is False
+ assert package_files["input.fa"]["exposed_as_input"] is True
+ assert not (
+ {"run.sh", "unused_helper.py"}
+ & {item["path"] for item in contract["inputs"]}
+ )
+
+
+def test_contract_digest_and_source_hash_come_from_locked_package_inventory(
+ tmp_path: Path,
+) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+
+ contract = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ canonical_inventory = json.dumps(
+ facts["package"]["inventory"],
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ assert contract["package_digest"] == hashlib.sha256(
+ canonical_inventory
+ ).hexdigest()
+ assert contract["source_scripts"] == [
+ {
+ "path": "run.sh",
+ "sha256": facts["package"]["inventory"]["run.sh"]["sha256"],
+ "locked": True,
+ }
+ ]
+ assert contract["contract_digest"] == hashlib.sha256(
+ json.dumps(
+ {
+ key: value
+ for key, value in contract.items()
+ if key != "contract_digest"
+ },
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ ).hexdigest()
+
+
+def test_contract_rejects_tampered_non_entry_code_file(tmp_path: Path) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+ (raw_code / "unused_helper.py").write_text(
+ "print('tampered code')\n", encoding="utf-8"
+ )
+
+ with pytest.raises(
+ ValueError, match=r"Code file changed after baseline: unused_helper\.py"
+ ):
+ build_runtime_binding_contract(facts, candidate, raw_code)
+
+
+def test_contract_exposes_only_explicit_non_path_parameters(tmp_path: Path) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+
+ contract = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ assert [
+ (item["kind"], item["flag"], item["value"])
+ for item in contract["parameters"]
+ ] == [("flag_value", "-n", "1")]
+ assert all(
+ item.get("value_role") != "output_reference"
+ for item in contract["parameters"]
+ )
+ assert "input.fa" not in {item["value"] for item in contract["parameters"]}
+ assert "result.fa" not in {item["value"] for item in contract["parameters"]}
+ assert [item["path"] for item in contract["path_references"]] == [
+ "result.fa"
+ ]
+ reference = contract["path_references"][0]
+ assert reference["value_role"] == "output_reference"
+ assert any(
+ target["binding_key"] == f"path_reference:{reference['fact_id']}"
+ for target in contract["patch_targets"]
+ )
+
+
+def test_semantics_can_only_add_text_bound_to_known_fact_ids(tmp_path: Path) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+ baseline_id = next(
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["inputs"]
+ if item["source"] == "baseline_input"
+ )
+ parameter_id = next(
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["parameters"]
+ if item.get("value_role") == "value"
+ )
+ candidate["original_command"] = "rm -rf /"
+ candidate["inputs"][0].update(
+ {"path": "invented.fa", "replaceability": "fixed_resource"}
+ )
+ next(
+ item
+ for item in candidate["parameters"]
+ if item["fact_id"] == parameter_id
+ ).update({"flag": "--invented", "value": "invented"})
+ candidate["outputs"][0]["path"] = "invented.out"
+
+ contract = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ input_item = _by_fact_id(contract["inputs"])[baseline_id]
+ parameter_item = _by_fact_id(contract["parameters"])[parameter_id]
+ assert contract["original_command"] == facts["original_command"]
+ assert input_item["path"] == "input.fa"
+ assert input_item["replaceable"] is True
+ assert input_item["role"] == "uploaded_sequences"
+ assert input_item["description"] == "User-provided sequence data."
+ assert parameter_item["flag"] == "-n"
+ assert parameter_item["value"] == "1"
+ assert parameter_item["display_name"] == "user-facing label"
+ assert all(
+ item["path"] != "invented.out"
+ for item in contract["outputs"]["files"]
+ )
+
+
+def test_shell_patch_targets_record_hash_literal_kind_and_exact_count(
+ tmp_path: Path,
+) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+
+ contract = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ targets = {
+ (item["patch_kind"], item["original_literal"]): item
+ for item in contract["patch_targets"]
+ }
+ script_hash = facts["package"]["inventory"]["run.sh"]["sha256"]
+ assert targets[("input_path", "input.fa")]["expected_exact_match_count"] == 1
+ assert targets[("parameter_value", "1")]["expected_exact_match_count"] == 1
+ assert targets[("parameter_value", "1")]["token_index"] == 2
+ assert targets[("output_path", "result.fa")]["expected_exact_match_count"] == 1
+ assert (
+ targets[("output_path", "intermediate.fa")][
+ "expected_exact_match_count"
+ ]
+ == 2
+ )
+ assert all(item["script_path"] == "run.sh" for item in targets.values())
+ assert all(item["script_hash"] == script_hash for item in targets.values())
+ assert all(
+ {"binding_key", "fact_ids", "anchors"} <= set(item)
+ and all(
+ {"step_id", "token_index", "command_digest"} <= set(anchor)
+ for anchor in item["anchors"]
+ )
+ for item in targets.values()
+ )
+ result_target = targets[("output_path", "result.fa")]
+ result_fact = next(
+ item
+ for item in facts["outputs"]["files"]
+ if item["path"] == "result.fa"
+ )
+ assert result_target["producer_step_ids"] == result_fact["producer_step_ids"]
+ assert result_target["step_id"] == "step_002"
+ assert (
+ result_target["command_digest"]
+ == hashlib.sha256(b"cp intermediate.fa result.fa").hexdigest()
+ )
+ intermediate_target = targets[("output_path", "intermediate.fa")]
+ intermediate_fact_ids = {
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["inputs"]
+ if item["path"] == "intermediate.fa"
+ } | {
+ item["fact_id"]
+ for item in facts["outputs"]["files"]
+ if item["path"] == "intermediate.fa"
+ }
+ assert intermediate_target["binding_key"] == "path:intermediate.fa"
+ assert set(intermediate_target["fact_ids"]) == intermediate_fact_ids
+ assert {anchor["step_id"] for anchor in intermediate_target["anchors"]} == {
+ "step_001",
+ "step_002",
+ }
+
+
+def test_same_uploaded_path_in_two_steps_forms_one_safe_path_binding(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "head input.fa > first.txt\n"
+ "tail input.fa > last.txt\n",
+ )
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ facts = build_execution_facts(evidence)
+
+ contract = build_runtime_binding_contract(facts, {}, raw_code)
+
+ input_fact_ids = {
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["inputs"]
+ if item["path"] == "input.fa"
+ }
+ targets = [
+ item
+ for item in contract["patch_targets"]
+ if item["binding_key"] == "path:input.fa"
+ ]
+ assert len(input_fact_ids) == 2
+ assert len(targets) == 1
+ assert set(targets[0]["fact_ids"]) == input_fact_ids
+ assert len(targets[0]["anchors"]) == 2
+ assert targets[0]["expected_exact_match_count"] == 2
+
+
+def test_inline_flag_value_is_not_patchable_in_v1(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "run.sh", "head --lines=1 input.fa > result.fa\n")
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ facts = build_execution_facts(evidence)
+
+ contract = build_runtime_binding_contract(facts, {}, raw_code)
+
+ parameter_fact = next(
+ item
+ for command in facts["commands"]
+ for item in command["parameters"]
+ if item.get("flag") == "--lines"
+ )
+ assert not any(
+ item["binding_key"] == f"parameter:{parameter_fact['fact_id']}"
+ for item in contract["patch_targets"]
+ )
+ blocked = next(
+ item
+ for item in contract["unpatchable_targets"]
+ if item["binding_key"] == f"parameter:{parameter_fact['fact_id']}"
+ )
+ assert blocked["reason"] == "inline_flag_value_not_supported"
+
+
+def test_repeated_parameter_literal_across_facts_is_not_patchable(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "head -n 1 input.fa > first.txt\n"
+ "tail -n 1 input.fa > last.txt\n",
+ )
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ facts = build_execution_facts(evidence)
+
+ contract = build_runtime_binding_contract(facts, {}, raw_code)
+
+ parameter_fact_ids = {
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["parameters"]
+ if item.get("value") == "1"
+ }
+ assert len(parameter_fact_ids) == 2
+ assert not any(
+ item["patch_kind"] == "parameter_value"
+ and item["original_literal"] == "1"
+ for item in contract["patch_targets"]
+ )
+ ambiguous = {
+ item["fact_ids"][0]
+ for item in contract["unpatchable_targets"]
+ if item["patch_kind"] == "parameter_value"
+ and item["original_literal"] == "1"
+ and item["reason"] == "source_literal_ambiguous"
+ }
+ assert ambiguous == parameter_fact_ids
+
+
+def test_contract_is_deterministic_and_does_not_mutate_inputs(tmp_path: Path) -> None:
+ facts, candidate, raw_code = _package(tmp_path)
+ original_facts = deepcopy(facts)
+ original_candidate = deepcopy(candidate)
+
+ first = build_runtime_binding_contract(facts, candidate, raw_code)
+ second = build_runtime_binding_contract(facts, candidate, raw_code)
+
+ assert first == second
+ assert facts == original_facts
+ assert candidate == original_candidate
+
+
+def test_contract_records_unpatchable_reason_without_guessing_for_direct_command(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ raw_code.mkdir()
+ _write(raw_data, "input.fa", ">a\nAAAA\n")
+ evidence = run_raw_baseline(
+ command="head -n 1 input.fa > result.fa",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ facts = build_execution_facts(evidence)
+
+ contract = build_runtime_binding_contract(facts, {}, raw_code)
+
+ assert contract["source_scripts"] == []
+ assert contract["patch_targets"] == []
+ assert contract["unpatchable_targets"]
+ assert {item["reason"] for item in contract["unpatchable_targets"]} == {
+ "source_literal_not_found_in_raw_shell"
+ }
diff --git a/tests/unit/test_extension_stage_lightweight_runtime_binding_resolver.py b/tests/unit/test_extension_stage_lightweight_runtime_binding_resolver.py
new file mode 100644
index 000000000..a871e9bb8
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_runtime_binding_resolver.py
@@ -0,0 +1,630 @@
+from __future__ import annotations
+
+from copy import deepcopy
+import json
+from pathlib import Path
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.runtime_binding import (
+ build_runtime_binding_contract,
+ resolve_runtime_bindings,
+)
+
+
+CATALOG_ROOT = (
+ Path(__file__).resolve().parents[2]
+ / "benchmarks"
+ / "extension_stage_lightweight_gene_family_8x5"
+)
+
+
+def _file(tmp_path: Path, name: str) -> Path:
+ path = tmp_path / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("test\n", encoding="utf-8")
+ return path
+
+
+def _input(
+ fact_id: str,
+ path: str,
+ role: str | None = None,
+ step_id: str = "step_001",
+) -> dict:
+ item = {
+ "fact_id": fact_id,
+ "path": path,
+ "step_id": step_id,
+ "source": "baseline_input",
+ "replaceable": True,
+ "locked": False,
+ }
+ if role:
+ item["role"] = role
+ return item
+
+
+def _contract(
+ *,
+ inputs: list[dict],
+ outputs: list[tuple[str, str]],
+ parameters: list[dict] | None = None,
+ stdout: bool = False,
+) -> dict:
+ return {
+ "schema_version": "lightweight-runtime-binding/v1",
+ "facts_sha256": "facts-1",
+ "package_digest": "package-1",
+ "inputs": inputs,
+ "parameters": parameters or [],
+ "outputs": {
+ "files": [
+ {
+ "fact_id": fact_id,
+ "path": path,
+ "producer_step_ids": ["step_001"],
+ }
+ for fact_id, path in outputs
+ ],
+ "stdout": {"present": stdout},
+ },
+ }
+
+
+def _outputs(result: dict) -> dict[str, dict]:
+ return {item["fact_id"]: item for item in result["output_bindings"]}
+
+
+def test_resolves_single_input_and_output_prefix(tmp_path: Path) -> None:
+ replacement = _file(tmp_path, "Runtime.Sample.pep.fasta")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.SPL.pep.fasta", "sequences")],
+ outputs=[("output:1", "Ft.SPL.pep.mfa")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert result["error"] is None
+ assert result["input_bindings"] == [
+ {
+ "original_path": "Ft.SPL.pep.fasta",
+ "runtime_path": str(replacement),
+ "fact_ids": ["input:1"],
+ }
+ ]
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.mfa"
+ assert result["prefix_binding"]["mode"] == "default"
+
+
+def test_consistent_multi_input_prefixes_resolve_once(tmp_path: Path) -> None:
+ first = _file(tmp_path, "Runtime.one.fa")
+ second = _file(tmp_path, "Runtime.two.fa")
+ contract = _contract(
+ inputs=[
+ _input("input:1", "Ft.one.fa"),
+ _input("input:2", "Ft.two.fa"),
+ ],
+ outputs=[("output:1", "Ft.result.txt")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": first, "input:2": second}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.result.txt"
+
+
+def test_unrelated_model_input_does_not_participate_in_output_prefix(
+ tmp_path: Path,
+) -> None:
+ model = _file(tmp_path, "RuntimeModel.hmm")
+ sequences = _file(tmp_path, "Runtime.pep.fasta")
+ contract = _contract(
+ inputs=[
+ _input("input:model", "SBP.hmm"),
+ _input("input:sequences", "Ft.pep.fasta"),
+ ],
+ outputs=[("output:1", "Ft.domtblout")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract,
+ {"input:model": model, "input:sequences": sequences},
+ {},
+ )
+
+ assert result["status"] == "resolved"
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.domtblout"
+
+
+def test_separator_change_without_evidence_uses_default_output_prefix(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Runtime-Reference.collinearity")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft-Ath.collinearity")],
+ outputs=[("output:1", "Ft.Ath.anchors")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert result["prefix_binding"] == {
+ "mode": "default",
+ "old_prefix": "Ft.Ath",
+ "new_prefix": "output",
+ "participating_inputs": [],
+ }
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.anchors"
+
+
+def test_no_input_output_name_relation_uses_default_output_prefix(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Runtime.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "input.fa")],
+ outputs=[("output:1", "result.txt")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert result["prefix_binding"]["mode"] == "default"
+ assert result["prefix_binding"]["old_prefix"] == "result"
+ assert result["prefix_binding"]["new_prefix"] == "output"
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.txt"
+
+
+def test_common_output_prefix_is_replaced_without_input_name_relation(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Runtime.Sample.genomic.gff")
+ contract = _contract(
+ inputs=[_input("input:1", "GCF_reference_genomic.gff")],
+ outputs=[
+ ("output:tmp", "Ath.gff3.tmp"),
+ ("output:gff", "Ath_final.gff3"),
+ ("output:ids", "Ath_gene_mrna_cds.ids"),
+ ],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert result["prefix_binding"]["old_prefix"] == "Ath"
+ assert result["prefix_binding"]["new_prefix"] == "output"
+ assert {
+ fact_id: item["runtime_path"]
+ for fact_id, item in _outputs(result).items()
+ } == {
+ "output:tmp": "output.gff3.tmp",
+ "output:gff": "output_final.gff3",
+ "output:ids": "output_gene_mrna_cds.ids",
+ }
+
+
+def test_auxiliary_output_stays_unchanged_after_another_output_is_derived(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Runtime.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[
+ ("output:main", "Ft.result.txt"),
+ ("output:aux", "summary.log"),
+ ],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert _outputs(result)["output:main"]["runtime_path"] == "output.result.txt"
+ assert _outputs(result)["output:aux"] == {
+ "fact_id": "output:aux",
+ "original_path": "summary.log",
+ "runtime_path": "summary.log",
+ "transformed": False,
+ }
+
+
+def test_conflicting_input_prefixes_use_default_and_accept_explicit_prefix(
+ tmp_path: Path,
+) -> None:
+ first = _file(tmp_path, "X.one.fa")
+ second = _file(tmp_path, "Y.two.fa")
+ contract = _contract(
+ inputs=[
+ _input("input:1", "Ft.one.fa"),
+ _input("input:2", "Ft.two.fa"),
+ ],
+ outputs=[("output:1", "Ft.result.txt")],
+ )
+
+ blocked = resolve_runtime_bindings(
+ contract, {"input:1": first, "input:2": second}, {}
+ )
+ resolved = resolve_runtime_bindings(
+ contract,
+ {"input:1": first, "input:2": second},
+ {},
+ explicit_prefix="Chosen",
+ )
+
+ assert blocked["status"] == "resolved"
+ assert blocked["prefix_binding"]["mode"] == "default"
+ assert _outputs(blocked)["output:1"]["runtime_path"] == "output.result.txt"
+ assert resolved["status"] == "resolved"
+ assert resolved["prefix_binding"]["mode"] == "explicit"
+ assert _outputs(resolved)["output:1"]["runtime_path"] == "Chosen.result.txt"
+
+
+def test_different_source_prefixes_use_declared_output_with_explicit_prefix(
+ tmp_path: Path,
+) -> None:
+ first = _file(tmp_path, "X.one.fa")
+ second = _file(tmp_path, "Y.two.fa")
+ contract = _contract(
+ inputs=[
+ _input("input:1", "Ft.one.fa"),
+ _input("input:2", "Ft.SPL.two.fa"),
+ ],
+ outputs=[("output:1", "Ft.SPL.result.txt")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract,
+ {"input:1": first, "input:2": second},
+ {},
+ explicit_prefix="Chosen",
+ )
+
+ assert result["status"] == "resolved"
+ assert result["prefix_binding"]["mode"] == "explicit"
+ assert _outputs(result)["output:1"]["runtime_path"] == "Chosen.txt"
+
+
+def test_duplicate_input_facts_group_by_path_and_conflicts_are_blocked(
+ tmp_path: Path,
+) -> None:
+ first = _file(tmp_path, "Runtime.fa")
+ other = _file(tmp_path, "Other.fa")
+ contract = _contract(
+ inputs=[
+ _input("input:1", "Ft.fa", "sequences"),
+ _input("input:2", "Ft.fa", "sequences"),
+ ],
+ outputs=[("output:1", "Ft.out")],
+ )
+
+ grouped = resolve_runtime_bindings(
+ contract, {"sequences": first}, {}
+ )
+ conflict = resolve_runtime_bindings(
+ contract, {"input:1": first, "input:2": other}, {}
+ )
+
+ assert grouped["status"] == "resolved"
+ assert grouped["input_bindings"][0]["fact_ids"] == ["input:1", "input:2"]
+ assert conflict["error"]["code"] == "INPUT_BINDING_CONFLICT"
+
+
+def test_missing_unknown_and_nonexistent_inputs_have_stable_codes(
+ tmp_path: Path,
+) -> None:
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out")],
+ )
+
+ missing = resolve_runtime_bindings(contract, {}, {})
+ unknown = resolve_runtime_bindings(
+ contract, {"invented": _file(tmp_path, "Runtime.fa")}, {}
+ )
+ nonexistent = resolve_runtime_bindings(
+ contract, {"input:1": tmp_path / "absent.fa"}, {}
+ )
+
+ assert missing["error"]["code"] == "INPUT_BINDING_MISSING"
+ assert unknown["error"]["code"] == "INPUT_NOT_DECLARED"
+ assert nonexistent["error"]["code"] == "INPUT_PATH_NOT_FOUND"
+
+
+def test_stdout_only_contract_does_not_need_output_prefix(tmp_path: Path) -> None:
+ replacement = _file(tmp_path, "Runtime.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "input.fa")],
+ outputs=[],
+ stdout=True,
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input.fa": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert result["prefix_binding"]["mode"] == "none"
+ assert result["output_bindings"] == []
+
+
+def test_parameter_overrides_only_declared_values_and_safe_switches(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Ft.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out")],
+ parameters=[
+ {
+ "fact_id": "parameter:model",
+ "kind": "flag_value",
+ "flag": "-m",
+ "value": "MFP",
+ },
+ {
+ "fact_id": "parameter:threshold",
+ "kind": "switch",
+ "flag": "--cut_tc",
+ "value": "--cut_tc",
+ },
+ {
+ "fact_id": "parameter:count",
+ "kind": "positional",
+ "flag": None,
+ "value": "10",
+ },
+ ],
+ )
+
+ result = resolve_runtime_bindings(
+ contract,
+ {"input:1": replacement},
+ {
+ "parameter:model": "LG+G4",
+ "parameter:threshold": "--cut_ga",
+ "parameter:count": 20,
+ },
+ )
+
+ bindings = {item["fact_id"]: item for item in result["parameter_bindings"]}
+ assert result["status"] == "resolved"
+ assert bindings["parameter:model"]["flag"] == "-m"
+ assert bindings["parameter:model"]["runtime_value"] == "LG+G4"
+ assert bindings["parameter:threshold"]["runtime_value"] == "--cut_ga"
+ assert bindings["parameter:count"]["runtime_value"] == "20"
+
+
+def test_unknown_and_unsafe_parameter_overrides_are_blocked(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Ft.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out")],
+ parameters=[
+ {
+ "fact_id": "parameter:threshold",
+ "kind": "switch",
+ "flag": "--cut_tc",
+ "value": "--cut_tc",
+ }
+ ],
+ )
+
+ unknown = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {"parameter:other": "x"}
+ )
+ whitespace = resolve_runtime_bindings(
+ contract,
+ {"input:1": replacement},
+ {"parameter:threshold": "--cut_ga extra"},
+ )
+ control = resolve_runtime_bindings(
+ contract,
+ {"input:1": replacement},
+ {"parameter:threshold": "--cut_ga\nrm"},
+ )
+
+ assert unknown["error"]["code"] == "PARAMETER_NOT_DECLARED"
+ assert unknown["error"]["available_fact_ids"] == ["parameter:threshold"]
+ assert whitespace["error"]["code"] == "PARAMETER_VALUE_INVALID"
+ assert control["error"]["code"] == "PARAMETER_VALUE_INVALID"
+
+
+def test_resolution_is_deterministic_json_and_does_not_mutate_arguments(
+ tmp_path: Path,
+) -> None:
+ replacement = _file(tmp_path, "Runtime.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out")],
+ )
+ input_overrides = {"input:1": replacement}
+ parameter_overrides: dict = {}
+ original_contract = deepcopy(contract)
+ original_inputs = dict(input_overrides)
+
+ first = resolve_runtime_bindings(
+ contract, input_overrides, parameter_overrides
+ )
+ second = resolve_runtime_bindings(
+ contract, input_overrides, parameter_overrides
+ )
+
+ assert first == second
+ assert len(first["binding_digest"]) == 64
+ assert contract == original_contract
+ assert input_overrides == original_inputs
+ assert parameter_overrides == {}
+
+
+def test_dev_contracts_match_all_runtime_binding_gold_cases(
+ tmp_path: Path,
+) -> None:
+ if not (CATALOG_ROOT / "runtime_binding_dev_gold.json").is_file():
+ pytest.skip("runtime-binding benchmark data is outside this migration")
+
+ gold = json.loads(
+ (CATALOG_ROOT / "runtime_binding_dev_gold.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ attempt = json.loads(
+ (CATALOG_ROOT / "dev_api_results_attempt_007.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ actual_by_case = {item["case_id"]: item for item in attempt["cases"]}
+ counts = {"resolved": 0, "blocked": 0}
+
+ for expected in gold["cases"]:
+ case_id = expected["case_id"]
+ actual = actual_by_case[case_id]
+ materialized = CATALOG_ROOT / "materialized" / case_id
+ contract = build_runtime_binding_contract(
+ actual["execution_facts"],
+ actual["final_semantic"]["candidate"],
+ materialized / "raw_code",
+ )
+ overrides = {}
+ for binding in expected["input_overrides"]:
+ source = materialized / "raw_test_data" / binding["original_path"]
+ replacement = tmp_path / case_id / binding["replacement_path"]
+ replacement.parent.mkdir(parents=True, exist_ok=True)
+ replacement.write_bytes(source.read_bytes())
+ overrides[binding["fact_id"]] = replacement
+
+ result = resolve_runtime_bindings(contract, overrides, {})
+ counts[result["status"]] += 1
+ wanted_status = (
+ "resolved" if expected["expected_status"] == "runnable" else "blocked"
+ )
+ assert result["status"] == wanted_status, case_id
+ if wanted_status == "blocked":
+ assert result["error"]["code"] == expected["blocked"]["code"], case_id
+ continue
+
+ assert result["prefix_binding"]["old_prefix"] == expected["evidence"]["old_prefix"], case_id
+ assert result["prefix_binding"]["new_prefix"] == expected["evidence"]["new_prefix"], case_id
+ actual_outputs = _outputs(result)
+ for output in expected["expected_output_path_transforms"]:
+ assert actual_outputs[output["fact_id"]]["runtime_path"] == output["expected_path"], case_id
+ assert actual_outputs[output["fact_id"]]["transformed"] is output["transformed"], case_id
+
+ assert counts == {"resolved": 16, "blocked": 0}
+
+
+def test_colliding_runtime_output_paths_are_blocked(tmp_path: Path) -> None:
+ replacement = _file(tmp_path, "Runtime.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out"), ("output:2", "output.out")],
+ )
+
+ result = resolve_runtime_bindings(contract, {"input:1": replacement}, {})
+
+ assert result["status"] == "blocked"
+ assert result["error"]["code"] == "OUTPUT_PATH_COLLISION"
+
+
+def test_explicit_and_inferred_prefixes_share_strict_validation(
+ tmp_path: Path,
+) -> None:
+ contract = _contract(
+ inputs=[_input("input:1", "Ft.fa")],
+ outputs=[("output:1", "Ft.out")],
+ )
+ safe_input = _file(tmp_path, "Runtime.fa")
+ unsafe_inferred = _file(tmp_path, "Runtime\nName.fa")
+
+ inferred = resolve_runtime_bindings(
+ contract, {"input:1": unsafe_inferred}, {}
+ )
+ explicit_results = [
+ resolve_runtime_bindings(
+ contract,
+ {"input:1": safe_input},
+ {},
+ explicit_prefix=value,
+ )
+ for value in ("with space", "$(touch_x)", "-leading")
+ ]
+
+ assert inferred["error"]["code"] == "INVALID_PREFIX"
+ assert [item["error"]["code"] for item in explicit_results] == [
+ "INVALID_PREFIX",
+ "INVALID_PREFIX",
+ "INVALID_PREFIX",
+ ]
+
+
+def test_symlink_runtime_name_drives_prefix_without_mutating_inputs(
+ tmp_path: Path,
+) -> None:
+ target = _file(tmp_path, "old_name.fa")
+ replacement = tmp_path / "new_name.fa"
+ replacement.symlink_to(target.name)
+ contract = _contract(
+ inputs=[_input("input:1", "old_name.fa")],
+ outputs=[("output:1", "old_name.out")],
+ )
+ overrides = {"input:1": replacement}
+ original_contract = deepcopy(contract)
+ original_overrides = dict(overrides)
+
+ result = resolve_runtime_bindings(contract, overrides, {})
+
+ assert result["status"] == "resolved"
+ assert Path(result["input_bindings"][0]["runtime_path"]).name == "new_name.fa"
+ assert result["prefix_binding"]["new_prefix"] == "output"
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.out"
+ assert contract == original_contract
+ assert overrides == original_overrides
+
+
+def test_symlink_separator_change_uses_default_output_prefix(
+ tmp_path: Path,
+) -> None:
+ target = _file(tmp_path, "Ft-Ath.collinearity")
+ replacement = tmp_path / "Runtime-Reference.collinearity"
+ replacement.symlink_to(target.name)
+ contract = _contract(
+ inputs=[_input("input:1", "Ft-Ath.collinearity")],
+ outputs=[("output:1", "Ft.Ath.anchors")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "resolved"
+ assert _outputs(result)["output:1"]["runtime_path"] == "output.anchors"
+
+
+def test_dangling_runtime_input_symlink_is_rejected(tmp_path: Path) -> None:
+ replacement = tmp_path / "new_name.fa"
+ replacement.symlink_to("missing.fa")
+ contract = _contract(
+ inputs=[_input("input:1", "old_name.fa")],
+ outputs=[("output:1", "old_name.out")],
+ )
+
+ result = resolve_runtime_bindings(
+ contract, {"input:1": replacement}, {}
+ )
+
+ assert result["status"] == "blocked"
+ assert result["error"]["code"] == "INPUT_PATH_NOT_FOUND"
diff --git a/tests/unit/test_extension_stage_lightweight_runtime_package.py b/tests/unit/test_extension_stage_lightweight_runtime_package.py
new file mode 100644
index 000000000..bee044d56
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_runtime_package.py
@@ -0,0 +1,1459 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import shlex
+import time
+
+import pytest
+
+import gfa_core.extension_stage_lightweight.runtime_binding as runtime_module
+from gfa_core.extension_stage_lightweight.command_model import (
+ _prepare_script_commands,
+)
+from gfa_core.extension_stage_lightweight.runtime_binding import (
+ RuntimeBindingError,
+ render_runtime_package,
+ replay_runtime_binding,
+ resolve_runtime_bindings,
+)
+
+
+def _sha256_bytes(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def _sha256_json(value: object) -> str:
+ payload = json.dumps(
+ value,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _refresh_digest(value: dict, field: str) -> None:
+ value[field] = _sha256_json(
+ {key: item for key, item in value.items() if key != field}
+ )
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _target(
+ *,
+ binding_key: str,
+ literal: str,
+ kind: str,
+ script_hash: str,
+ count: int = 1,
+ fact_ids: list[str] | None = None,
+) -> dict:
+ return {
+ "binding_key": binding_key,
+ "fact_ids": fact_ids or [],
+ "original_literal": literal,
+ "patch_kind": kind,
+ "script_path": "run.sh",
+ "script_hash": script_hash,
+ "expected_exact_match_count": count,
+ }
+
+
+def _contract_and_resolved(
+ raw_code: Path,
+ runtime_input: Path,
+ *,
+ script: str,
+ original_input: str = "Ft.input.txt",
+ output_bindings: list[dict] | None = None,
+ parameter_bindings: list[dict] | None = None,
+ stdout: bool = False,
+ path_references: list[dict] | None = None,
+ patch_targets: list[dict] | None = None,
+ unpatchable_targets: list[dict] | None = None,
+) -> tuple[dict, dict]:
+ script_path = _write(raw_code, "run.sh", script)
+ script_hash = _sha256_bytes(script_path.read_bytes())
+ commands = _prepare_script_commands(script, [])
+ targets = []
+ for supplied in patch_targets or []:
+ target = dict(supplied)
+ anchors = []
+ for index, command in enumerate(commands, start=1):
+ for token_index, token in enumerate(shlex.split(command)):
+ if token == target["original_literal"]:
+ anchors.append(
+ {
+ "fact_id": target["fact_ids"][0],
+ "step_id": f"step_{index:03d}",
+ "command_digest": _sha256_bytes(command.encode()),
+ "token_index": token_index,
+ }
+ )
+ target["anchors"] = anchors
+ targets.append(target)
+ data_hash = _sha256_bytes(runtime_input.read_bytes())
+ contract = {
+ "schema_version": "lightweight-runtime-binding/v1",
+ "facts_sha256": "facts-locked",
+ "package_digest": "",
+ "original_command": "bash run.sh",
+ "source_scripts": [
+ {"path": "run.sh", "sha256": script_hash, "locked": True}
+ ],
+ "package_files": [
+ {
+ "path": "run.sh",
+ "source": "raw_code",
+ "kind": "file",
+ "size": script_path.stat().st_size,
+ "sha256": script_hash,
+ "locked": True,
+ "exposed_as_input": False,
+ },
+ {
+ "path": original_input,
+ "source": "raw_test_data",
+ "kind": "file",
+ "size": runtime_input.stat().st_size,
+ "sha256": data_hash,
+ "locked": False,
+ "exposed_as_input": True,
+ },
+ ],
+ "inputs": [
+ {
+ "fact_id": "input:1",
+ "path": original_input,
+ "source": "baseline_input",
+ "replaceable": True,
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": item["fact_id"],
+ "kind": item["kind"],
+ "flag": item["flag"],
+ "value": item["original_value"],
+ }
+ for item in (parameter_bindings or [])
+ ],
+ "path_references": path_references or [],
+ "outputs": {
+ "files": [
+ {
+ "fact_id": item["fact_id"],
+ "path": item["original_path"],
+ }
+ for item in (output_bindings or [])
+ ],
+ "stdout": {"present": stdout},
+ },
+ "patch_targets": targets,
+ "unpatchable_targets": unpatchable_targets or [],
+ }
+ inventory = {
+ item["path"]: {
+ key: item[key]
+ for key in ("kind", "size", "sha256", "source")
+ }
+ for item in contract["package_files"]
+ }
+ contract["package_digest"] = _sha256_json(inventory)
+ _refresh_digest(contract, "contract_digest")
+ resolved = resolve_runtime_bindings(
+ contract,
+ {"input:1": runtime_input},
+ {
+ item["fact_id"]: item["runtime_value"]
+ for item in (parameter_bindings or [])
+ },
+ )
+ assert resolved["status"] == "resolved", resolved
+ return contract, resolved
+
+
+def test_render_copies_and_patches_only_working_code_and_materializes_input(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "a\nb\n")
+ original = (
+ "cp Ft.input.txt Ft.tmp.txt\n"
+ "head -n 1 Ft.tmp.txt > Ft.output.txt\n"
+ "sort -u Ft.output.txt > Ft.sorted.txt\n"
+ )
+ script_hash = _sha256_bytes(original.encode())
+ outputs = [
+ {
+ "fact_id": "output:tmp",
+ "original_path": "Ft.tmp.txt",
+ "runtime_path": "Sample.tmp.txt",
+ "transformed": True,
+ },
+ {
+ "fact_id": "output:main",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ },
+ {
+ "fact_id": "output:sorted",
+ "original_path": "Ft.sorted.txt",
+ "runtime_path": "Sample.sorted.txt",
+ "transformed": True,
+ },
+ ]
+ parameters = [
+ {
+ "fact_id": "parameter:n",
+ "kind": "flag_value",
+ "flag": "-n",
+ "original_value": "1",
+ "runtime_value": "2",
+ "changed": True,
+ },
+ {
+ "fact_id": "parameter:sort",
+ "kind": "switch",
+ "flag": "-u",
+ "original_value": "-u",
+ "runtime_value": "-r",
+ "changed": True,
+ },
+ ]
+ targets = [
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.tmp.txt",
+ literal="Ft.tmp.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["output:tmp"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["output:main"],
+ ),
+ _target(
+ binding_key="path:Ft.sorted.txt",
+ literal="Ft.sorted.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:sorted"],
+ ),
+ _target(
+ binding_key="parameter:parameter:n",
+ literal="1",
+ kind="parameter_value",
+ script_hash=script_hash,
+ fact_ids=["parameter:n"],
+ ),
+ _target(
+ binding_key="parameter:parameter:sort",
+ literal="-u",
+ kind="parameter_switch",
+ script_hash=script_hash,
+ fact_ids=["parameter:sort"],
+ ),
+ ]
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=original,
+ output_bindings=outputs,
+ parameter_bindings=parameters,
+ patch_targets=targets,
+ )
+
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ assert (raw_code / "run.sh").read_text(encoding="utf-8") == original
+ patched = (tmp_path / "run/work/run.sh").read_text(encoding="utf-8")
+ assert "cp Sample.input.txt output.tmp.txt" in patched
+ assert "head -n 2 output.tmp.txt > output.output.txt" in patched
+ assert "sort -r output.output.txt > output.sorted.txt" in patched
+ materialized = tmp_path / "run/work/Sample.input.txt"
+ assert materialized.is_symlink()
+ assert materialized.resolve() == runtime_input.resolve()
+ assert not (tmp_path / "run/work/output.output.txt").exists()
+ record = json.loads(
+ (tmp_path / "run/runtime_binding_record.json").read_text(encoding="utf-8")
+ )
+ assert record == rendered
+ assert record["source_hashes"]["run.sh"] == script_hash
+ assert record["patch_diff"]
+
+
+def test_render_copies_input_when_the_same_path_is_a_modified_output(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.txt", "before\n")
+ script = "printf 'after\\n' >> Ft.input.txt\n"
+ script_hash = _sha256_bytes(script.encode())
+ outputs = [
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.input.txt",
+ "runtime_path": "Sample.txt",
+ "transformed": True,
+ }
+ ]
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=outputs,
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["input:1", "output:1"],
+ )
+ ],
+ )
+
+ rendered = render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ materialized = tmp_path / "run/work/Sample.txt"
+ assert materialized.is_file() and not materialized.is_symlink()
+ assert materialized.read_text(encoding="utf-8") == "before\n"
+ result = replay_runtime_binding(rendered)
+ assert result["contract_passed"] is True
+ assert runtime_input.read_text(encoding="utf-8") == "before\n"
+ assert materialized.read_text(encoding="utf-8") == "before\nafter\n"
+
+
+def test_render_rechecks_each_copied_source_hash(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "x\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ original_copy = runtime_module.shutil.copy2
+
+ def corrupt_copy(source: Path, destination: Path) -> Path:
+ copied = original_copy(source, destination)
+ if Path(source) == raw_code / "run.sh":
+ Path(destination).write_text("corrupted\n", encoding="utf-8")
+ return copied
+
+ monkeypatch.setattr(runtime_module.shutil, "copy2", corrupt_copy)
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "SOURCE_COPY_HASH_MISMATCH"
+
+
+@pytest.mark.parametrize(
+ ("mutation", "code"),
+ [
+ ("source_hash", "SOURCE_HASH_MISMATCH"),
+ ("match_count", "PATCH_MATCH_COUNT_MISMATCH"),
+ ("digest", "RESOLUTION_DIGEST_MISMATCH"),
+ ],
+)
+def test_render_fails_closed_on_hash_match_or_resolution_mismatch(
+ tmp_path: Path, mutation: str, code: str
+) -> None:
+ raw_code = tmp_path / mutation / "raw_code"
+ runtime_input = _write(tmp_path, f"{mutation}/Sample.input.txt", "x\n")
+ script = "cp Ft.input.txt Ft.output.txt\n"
+ script_hash = _sha256_bytes(script.encode())
+ outputs = [
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ }
+ ]
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=outputs,
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:1"],
+ ),
+ ],
+ )
+ if mutation == "source_hash":
+ (raw_code / "run.sh").write_text(script + "# changed\n", encoding="utf-8")
+ elif mutation == "match_count":
+ contract["patch_targets"][0]["expected_exact_match_count"] = 2
+ _refresh_digest(contract, "contract_digest")
+ resolved["contract_digest"] = contract["contract_digest"]
+ _refresh_digest(resolved, "binding_digest")
+ else:
+ resolved["binding_digest"] = "0" * 64
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(
+ contract, raw_code, resolved, tmp_path / mutation / "run"
+ )
+
+ assert caught.value.code == code
+
+
+def test_render_rejects_changed_binding_with_only_unpatchable_target(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "x\n")
+ script = "cp Ft.input.txt Ft.output.txt\n"
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=[
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Ft.output.txt",
+ "transformed": False,
+ }
+ ],
+ unpatchable_targets=[
+ {
+ "binding_key": "path:Ft.input.txt",
+ "fact_ids": ["input:1"],
+ "original_literal": "Ft.input.txt",
+ "patch_kind": "input_path",
+ "reason": "source_literal_not_found_in_raw_shell",
+ }
+ ],
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "PATCH_TARGET_UNAVAILABLE"
+
+
+@pytest.mark.parametrize("bad_path", ["../run.sh", "/tmp/run.sh"])
+def test_render_rejects_code_traversal(tmp_path: Path, bad_path: str) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "x\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ contract["package_files"][0]["path"] = bad_path
+ _refresh_digest(contract, "contract_digest")
+ resolved["contract_digest"] = contract["contract_digest"]
+ _refresh_digest(resolved, "binding_digest")
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "UNSAFE_RELATIVE_PATH"
+
+
+def test_render_rejects_input_basename_and_code_collisions(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/run.sh", "not code\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "RUNTIME_PATH_COLLISION"
+
+
+def test_replay_executes_two_step_file_contract_and_records_logs(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "a\nb\n")
+ script = (
+ "cp Ft.input.txt Ft.tmp.txt\n"
+ "head -n 1 Ft.tmp.txt > Ft.output.txt\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ outputs = [
+ {
+ "fact_id": "output:tmp",
+ "original_path": "Ft.tmp.txt",
+ "runtime_path": "Sample.tmp.txt",
+ "transformed": True,
+ },
+ {
+ "fact_id": "output:main",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ },
+ ]
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=outputs,
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.tmp.txt",
+ literal="Ft.tmp.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["output:tmp"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:main"],
+ ),
+ ],
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ result = replay_runtime_binding(rendered)
+
+ assert result["success"] is True
+ assert result["returncode"] == 0
+ assert result["contract_passed"] is True
+ assert result["missing_outputs"] == []
+ assert {item["runtime_path"] for item in result["outputs"]} == {
+ "output.tmp.txt",
+ "output.output.txt",
+ }
+ assert (tmp_path / "run/work/output.output.txt").read_text(
+ encoding="utf-8"
+ ) == "a\n"
+ for path in result["logs"].values():
+ assert Path(path).is_file()
+ events = [
+ json.loads(line)
+ for line in Path(result["logs"]["events"]).read_text(
+ encoding="utf-8"
+ ).splitlines()
+ ]
+ assert [item["event"] for item in events] == ["start", "finish"]
+ assert events[-1]["returncode"] == 0
+ assert events[-1]["contract_passed"] is True
+ assert events[-1]["missing_outputs"] == []
+ assert events[-1]["failure_type"] is None
+ result_path = tmp_path / "run/runtime_execution_result.json"
+ assert json.loads(result_path.read_text(encoding="utf-8")) == result
+
+
+def test_replay_stdout_contract_has_no_arbitrary_timeout(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\nb\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="sleep 0.05\nwc -l Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ started = time.monotonic()
+ result = replay_runtime_binding(rendered)
+
+ assert time.monotonic() - started >= 0.04
+ assert result["success"] is True
+ assert result["contract_passed"] is True
+ assert Path(result["logs"]["stdout"]).read_text(encoding="utf-8").strip() == "2 Ft.input.txt"
+
+
+def test_replay_rejects_a_detached_work_directory(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ detached = tmp_path / "run/other"
+ detached.mkdir()
+ rendered["work_dir"] = str(detached)
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered)
+
+ assert caught.value.code == "RENDERED_PACKAGE_INVALID"
+
+
+def test_replay_rejects_user_bash_env_and_scrubs_inherited_bash_env(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ injected = _write(
+ tmp_path,
+ "injected.sh",
+ f"printf injected > {tmp_path / 'injected.marker'}\n",
+ )
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered, env={"BASH_ENV": str(injected)})
+ assert caught.value.code == "UNSAFE_ENVIRONMENT"
+ assert not (tmp_path / "injected.marker").exists()
+
+ monkeypatch.setenv("BASH_ENV", str(injected))
+ result = replay_runtime_binding(rendered)
+ assert result["contract_passed"] is True
+ assert not (tmp_path / "injected.marker").exists()
+
+
+def test_render_rejects_resolved_with_deleted_outputs_even_after_rehash(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "a\n")
+ script = "cp Ft.input.txt Ft.output.txt\n"
+ script_hash = _sha256_bytes(script.encode())
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=[
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:1"],
+ ),
+ ],
+ )
+ resolved["output_bindings"] = []
+ _refresh_digest(resolved, "binding_digest")
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "RESOLUTION_CANONICAL_MISMATCH"
+
+
+def test_render_rejects_tampered_contract_and_reconstructed_package_digest(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ contract["outputs"]["stdout"]["present"] = False
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run-a")
+ assert caught.value.code == "CONTRACT_DIGEST_MISMATCH"
+
+ contract["outputs"]["stdout"]["present"] = True
+ contract["package_digest"] = "0" * 64
+ _refresh_digest(contract, "contract_digest")
+ resolved["package_digest"] = contract["package_digest"]
+ resolved["contract_digest"] = contract["contract_digest"]
+ _refresh_digest(resolved, "binding_digest")
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run-b")
+ assert caught.value.code == "PACKAGE_DIGEST_MISMATCH"
+
+
+def test_render_rejects_a_wrong_command_anchor(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "a\n")
+ script = "cp Ft.input.txt Ft.output.txt\n"
+ script_hash = _sha256_bytes(script.encode())
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=[
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:1"],
+ ),
+ ],
+ )
+ contract["patch_targets"][0]["anchors"][0]["command_digest"] = "0" * 64
+ _refresh_digest(contract, "contract_digest")
+ resolved["contract_digest"] = contract["contract_digest"]
+ _refresh_digest(resolved, "binding_digest")
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "PATCH_ANCHOR_MISMATCH"
+
+
+def test_render_rejects_symlinked_raw_code(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ external = _write(tmp_path, "external/run.sh", "cat Ft.input.txt\n")
+ (raw_code / "run.sh").unlink()
+ (raw_code / "run.sh").symlink_to(external)
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "SOURCE_SYMLINK_UNSAFE"
+
+
+def test_render_rejects_option_like_runtime_input_basename(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/--help", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ unpatchable_targets=[
+ {
+ "binding_key": "path:Ft.input.txt",
+ "fact_ids": ["input:1"],
+ "original_literal": "Ft.input.txt",
+ "patch_kind": "input_path",
+ "reason": "source_literal_not_found_in_raw_shell",
+ }
+ ],
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "UNSAFE_INPUT_BASENAME"
+
+
+@pytest.mark.parametrize(
+ "mutation",
+ ["work_script", "disk_record", "actual_command", "output_list"],
+)
+def test_replay_rejects_runtime_package_tampering(
+ tmp_path: Path, mutation: str
+) -> None:
+ case_root = tmp_path / mutation
+ raw_code = case_root / "raw_code"
+ runtime_input = _write(case_root, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, case_root / "run"
+ )
+ if mutation == "work_script":
+ (case_root / "run/work/run.sh").write_text("echo changed\n", encoding="utf-8")
+ expected_code = "WORK_PACKAGE_CHANGED"
+ elif mutation == "disk_record":
+ path = case_root / "run/runtime_binding_record.json"
+ disk = json.loads(path.read_text(encoding="utf-8"))
+ disk["actual_command"] = ":"
+ path.write_text(json.dumps(disk), encoding="utf-8")
+ expected_code = "RECORD_DIGEST_MISMATCH"
+ elif mutation == "actual_command":
+ rendered["actual_command"] = ":"
+ _refresh_digest(rendered, "record_digest")
+ expected_code = "RECORD_MISMATCH"
+ else:
+ rendered["expected_outputs"] = [
+ {
+ "fact_id": "invented",
+ "original_path": "invented.txt",
+ "runtime_path": "invented.txt",
+ }
+ ]
+ _refresh_digest(rendered, "record_digest")
+ expected_code = "RECORD_MISMATCH"
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered)
+
+ assert caught.value.code == expected_code
+
+
+def test_replay_rejects_runtime_input_content_drift(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "before\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ runtime_input.write_text("after\n", encoding="utf-8")
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered)
+
+ assert caught.value.code == "INPUT_CONTENT_CHANGED"
+
+
+def test_replay_does_not_search_a_valid_tool_path_for_bash(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ marker = tmp_path / "fake-bash.marker"
+ fake_bash = _write(
+ tmp_path,
+ "fake-bin/bash",
+ f"#!/bin/sh\nprintf fake > {marker}\n",
+ )
+ fake_bash.chmod(0o755)
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="printf 'ok\\n'\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ explicit = replay_runtime_binding(
+ rendered, env={"PATH": str(fake_bash.parent)}
+ )
+ assert explicit["contract_passed"] is True
+ assert not marker.exists()
+ assert explicit["bash_path"] in {"/usr/bin/bash", "/bin/bash"}
+ assert explicit["tool_path_entries"] == [str(fake_bash.parent.resolve())]
+ assert len(explicit["tool_path_digest"]) == 64
+
+
+def test_replay_allows_a_valid_explicit_path_for_external_tools(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ dummy = _write(
+ tmp_path,
+ "tools/dummy_tool",
+ "#!/bin/sh\nprintf 'dummy-ok\\n'\n",
+ )
+ dummy.chmod(0o755)
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="dummy_tool\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+
+ result = replay_runtime_binding(
+ rendered,
+ env={"PATH": os.pathsep.join([str(dummy.parent), "/usr/bin", "/bin"])},
+ )
+
+ assert result["contract_passed"] is True
+ assert Path(result["logs"]["stdout"]).read_text(encoding="utf-8").strip() == "dummy-ok"
+ assert result["tool_path_entries"][0] == str(dummy.parent.resolve())
+
+
+@pytest.mark.parametrize("tool_path", ["relative/bin:/usr/bin", ":/usr/bin", ""])
+def test_replay_rejects_relative_or_empty_explicit_tool_path(
+ tmp_path: Path, tool_path: str
+) -> None:
+ case_root = tmp_path / hashlib.sha256(tool_path.encode()).hexdigest()[:8]
+ raw_code = case_root / "raw_code"
+ runtime_input = _write(case_root, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="printf 'ok\\n'\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, case_root / "run"
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered, env={"PATH": tool_path})
+
+ assert caught.value.code == "UNSAFE_TOOL_PATH"
+
+
+def test_symlink_runtime_input_keeps_its_lexical_new_name(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ target = _write(tmp_path, "runtime/underlying-old-name.txt", "value\n")
+ runtime_input = tmp_path / "runtime/Sample.input.txt"
+ runtime_input.symlink_to(target)
+ script = "cp Ft.input.txt Ft.output.txt\n"
+ script_hash = _sha256_bytes(script.encode())
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ output_bindings=[
+ {
+ "fact_id": "output:1",
+ "original_path": "Ft.output.txt",
+ "runtime_path": "Sample.output.txt",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.output.txt",
+ literal="Ft.output.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:1"],
+ ),
+ ],
+ )
+
+ assert resolved["input_bindings"][0]["runtime_path"] == str(runtime_input.absolute())
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ assert rendered["input_materialization"][0]["materialized_path"] == "Sample.input.txt"
+ result = replay_runtime_binding(rendered)
+ assert result["contract_passed"] is True
+ assert (tmp_path / "run/work/output.output.txt").read_text(encoding="utf-8") == "value\n"
+
+
+def test_explicit_output_reference_covers_its_implicit_multi_outputs(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "value\n")
+ script = (
+ "tool() { cp \"$1\" \"$3.a\"; cp \"$1\" \"$3.b\"; }\n"
+ "tool Ft.input.txt --out Ft.prefix\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ reference = {
+ "fact_id": "reference:prefix",
+ "step_id": "step_003",
+ "kind": "flag_value",
+ "flag": "--out",
+ "path": "Ft.prefix",
+ "value": "Ft.prefix",
+ "value_role": "output_reference",
+ }
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ path_references=[reference],
+ output_bindings=[
+ {
+ "fact_id": "output:a",
+ "original_path": "Ft.prefix.a",
+ "runtime_path": "Sample.prefix.a",
+ "transformed": True,
+ },
+ {
+ "fact_id": "output:b",
+ "original_path": "Ft.prefix.b",
+ "runtime_path": "Sample.prefix.b",
+ "transformed": True,
+ },
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path_reference:reference:prefix",
+ literal="Ft.prefix",
+ kind="output_reference_path",
+ script_hash=script_hash,
+ fact_ids=["reference:prefix"],
+ ),
+ ],
+ )
+
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ result = replay_runtime_binding(rendered)
+
+ assert result["contract_passed"] is True
+ assert {item["runtime_path"] for item in result["outputs"]} == {
+ "output.prefix.a",
+ "output.prefix.b",
+ }
+
+
+def test_patched_input_path_covers_an_implicit_sidecar_output(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.fa", "value\n")
+ script = (
+ "indexer() { cp \"$1\" \"$1.idx\"; }\n"
+ "indexer Ft.input.fa\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ original_input="Ft.input.fa",
+ output_bindings=[
+ {
+ "fact_id": "output:index",
+ "original_path": "Ft.input.fa.idx",
+ "runtime_path": "Sample.input.fa.idx",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.fa",
+ literal="Ft.input.fa",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ )
+ ],
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ result = replay_runtime_binding(rendered)
+ assert result["contract_passed"] is True
+ assert (tmp_path / "run/work/Sample.input.fa.idx").is_file()
+
+
+def test_direct_output_patch_takes_priority_over_input_root_coverage(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Runtime.input", "value\n")
+ script = "cp Ft.input Ft.input.id\n"
+ script_hash = _sha256_bytes(script.encode())
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ original_input="Ft.input",
+ output_bindings=[
+ {
+ "fact_id": "output:id",
+ "original_path": "Ft.input.id",
+ "runtime_path": "Runtime.input.id",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input",
+ literal="Ft.input",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.input.id",
+ literal="Ft.input.id",
+ kind="output_path",
+ script_hash=script_hash,
+ fact_ids=["output:id"],
+ ),
+ ],
+ )
+
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ result = replay_runtime_binding(rendered)
+
+ assert result["contract_passed"] is True
+ assert (tmp_path / "run/work/output.id").read_text(
+ encoding="utf-8"
+ ) == "value\n"
+ assert {item["binding_key"] for item in rendered["patch_diff"]} == {
+ "path:Ft.input",
+ "path:Ft.input.id",
+ }
+
+
+def test_unrelated_implicit_output_remains_blocked(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.fa", "value\n")
+ script = (
+ "maker() { cp \"$1\" \"${1%%.*}.report\"; }\n"
+ "maker Ft.input.fa\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ contract, _resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ original_input="Ft.input.fa",
+ output_bindings=[
+ {
+ "fact_id": "output:report",
+ "original_path": "Ft.report",
+ "runtime_path": "Sample.report",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.fa",
+ literal="Ft.input.fa",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ )
+ ],
+ )
+ resolved = resolve_runtime_bindings(contract, {"input:1": runtime_input}, {})
+ assert resolved["status"] == "resolved"
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "PATCH_TARGET_UNAVAILABLE"
+
+
+def test_unpatchable_path_reference_reuses_same_literal_output_patch(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "value\n")
+ script = (
+ "cp Ft.input.txt Ft.result.txt\n"
+ "test -s Ft.result.txt\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ reference = {
+ "fact_id": "reference:result",
+ "step_id": "step_001",
+ "kind": "positional",
+ "flag": None,
+ "path": "Ft.result.txt",
+ "value": "Ft.result.txt",
+ "value_role": "output_reference",
+ }
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ path_references=[reference],
+ output_bindings=[
+ {
+ "fact_id": "output:result",
+ "original_path": "Ft.result.txt",
+ "runtime_path": "Sample.result.txt",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.result.txt",
+ literal="Ft.result.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["output:result"],
+ ),
+ ],
+ unpatchable_targets=[
+ {
+ "binding_key": "path_reference:reference:result",
+ "fact_ids": ["reference:result"],
+ "anchors": [],
+ "original_literal": "Ft.result.txt",
+ "patch_kind": "output_reference_path",
+ "reason": "source_literal_ambiguous",
+ }
+ ],
+ )
+
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ result = replay_runtime_binding(rendered)
+
+ assert result["contract_passed"] is True
+ output_patch = next(
+ item
+ for item in rendered["patch_diff"]
+ if item["binding_key"] == "path:Ft.result.txt"
+ )
+ assert output_patch["covered_binding_keys"] == [
+ "path_reference:reference:result"
+ ]
+ assert output_patch["covered_fact_ids"] == ["reference:result"]
+
+
+def test_same_literal_alias_with_different_replacement_fails_closed(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Sample.input.txt", "value\n")
+ script = (
+ "cp Ft.input.txt Ft.result.txt\n"
+ "test -s Ft.result.txt\n"
+ )
+ script_hash = _sha256_bytes(script.encode())
+ parameter = {
+ "fact_id": "parameter:conflict",
+ "kind": "positional",
+ "flag": None,
+ "original_value": "Ft.result.txt",
+ "runtime_value": "Other.result.txt",
+ "changed": True,
+ }
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script=script,
+ path_references=[
+ {
+ "fact_id": "reference:result",
+ "step_id": "step_001",
+ "kind": "positional",
+ "flag": None,
+ "path": "Ft.result.txt",
+ "value": "Ft.result.txt",
+ "value_role": "output_reference",
+ }
+ ],
+ parameter_bindings=[parameter],
+ output_bindings=[
+ {
+ "fact_id": "output:result",
+ "original_path": "Ft.result.txt",
+ "runtime_path": "Sample.result.txt",
+ "transformed": True,
+ }
+ ],
+ patch_targets=[
+ _target(
+ binding_key="path:Ft.input.txt",
+ literal="Ft.input.txt",
+ kind="input_path",
+ script_hash=script_hash,
+ fact_ids=["input:1"],
+ ),
+ _target(
+ binding_key="path:Ft.result.txt",
+ literal="Ft.result.txt",
+ kind="output_path",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["output:result"],
+ ),
+ _target(
+ binding_key="parameter:parameter:conflict",
+ literal="Ft.result.txt",
+ kind="parameter_value",
+ script_hash=script_hash,
+ count=2,
+ fact_ids=["parameter:conflict"],
+ ),
+ ],
+ unpatchable_targets=[
+ {
+ "binding_key": "path_reference:reference:result",
+ "fact_ids": ["reference:result"],
+ "anchors": [],
+ "original_literal": "Ft.result.txt",
+ "patch_kind": "output_reference_path",
+ "reason": "source_literal_ambiguous",
+ }
+ ],
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
+
+ assert caught.value.code == "PATCH_REPLACEMENT_CONFLICT"
+
+
+def test_replay_rejects_an_extra_glob_visible_work_file(tmp_path: Path) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "a\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="cat *.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ (tmp_path / "run/work/undeclared.txt").write_text(
+ "must not become input\n", encoding="utf-8"
+ )
+
+ with pytest.raises(RuntimeBindingError) as caught:
+ replay_runtime_binding(rendered)
+
+ assert caught.value.code == "WORK_TREE_CHANGED"
+
+
+def test_replay_isolates_symlink_input_before_source_overwrite(
+ tmp_path: Path,
+) -> None:
+ raw_code = tmp_path / "raw_code"
+ runtime_input = _write(tmp_path, "runtime/Ft.input.txt", "external\n")
+ contract, resolved = _contract_and_resolved(
+ raw_code,
+ runtime_input,
+ script="printf 'job-only\\n' > Ft.input.txt\ncat Ft.input.txt\n",
+ stdout=True,
+ )
+ rendered = render_runtime_package(
+ contract, raw_code, resolved, tmp_path / "run"
+ )
+ assert (tmp_path / "run/work/Ft.input.txt").is_symlink()
+
+ result = replay_runtime_binding(rendered)
+
+ materialized = tmp_path / "run/work/Ft.input.txt"
+ assert result["contract_passed"] is True
+ assert runtime_input.read_text(encoding="utf-8") == "external\n"
+ assert materialized.is_file() and not materialized.is_symlink()
+ assert materialized.read_text(encoding="utf-8") == "job-only\n"
+ assert result["execution_inputs"] == [
+ {
+ "materialized_path": "Ft.input.txt",
+ "mode": "isolated_copy",
+ "source_path": str(runtime_input.resolve()),
+ }
+ ]
+ assert result["execution_work_tree"]["Ft.input.txt"]["type"] == "regular"
diff --git a/tests/unit/test_extension_stage_lightweight_semantic_model.py b/tests/unit/test_extension_stage_lightweight_semantic_model.py
new file mode 100644
index 000000000..2c431aaef
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_semantic_model.py
@@ -0,0 +1,472 @@
+from __future__ import annotations
+
+from copy import deepcopy
+import importlib
+from pathlib import Path
+
+import pytest
+
+from gfa_core.extension_stage_lightweight.converter import build_execution_facts
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _semantic_model():
+ return importlib.import_module("gfa_core.extension_stage_lightweight.semantic_model")
+
+
+def _facts(tmp_path: Path) -> dict:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "run.sh", "cp input.fa result.txt\n")
+ _write(raw_data, "input.fa", ">a\nAAAA\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ return build_execution_facts(evidence)
+
+
+def _facts_with_generated_input(tmp_path: Path) -> dict:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(
+ raw_code,
+ "run.sh",
+ "cp input.fa intermediate.fa\ncp intermediate.fa result.fa\n",
+ )
+ _write(raw_data, "input.fa", ">a\nAAAA\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ return build_execution_facts(evidence)
+
+
+def _candidate(facts: dict) -> dict:
+ command = facts["commands"][0]
+ return {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy an uploaded FASTA file to a result file.",
+ "inputs": [
+ {
+ "fact_id": command["inputs"][0]["fact_id"],
+ "role": "input_sequences",
+ "description": "Uploaded FASTA sequences.",
+ "replaceability": "replaceable",
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": command["parameters"][0]["fact_id"],
+ "display_name": "result file",
+ "description": "Output filename from the original command.",
+ }
+ ],
+ "outputs": [
+ {
+ "fact_id": facts["outputs"]["files"][0]["fact_id"],
+ "description": "Copied FASTA result.",
+ "result_role": "final",
+ }
+ ],
+ }
+
+
+def test_valid_candidate_references_only_locked_fact_ids(tmp_path: Path) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+
+ assert model.validate_semantic_candidate(facts, candidate) == []
+ merged = model.merge_semantics(facts, candidate)
+
+ assert merged == {
+ "execution_facts": facts,
+ "semantics": candidate,
+ }
+ assert merged["execution_facts"] is not facts
+ assert merged["semantics"] is not candidate
+
+
+def test_candidate_rejects_fact_digest_mismatch(tmp_path: Path) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ candidate["facts_sha256"] = "0" * 64
+
+ errors = model.validate_semantic_candidate(facts, candidate)
+
+ assert any(
+ error["field"] == "facts_sha256" and error["code"] == "facts_mismatch"
+ for error in errors
+ )
+
+
+def test_candidate_rejects_missing_duplicate_and_unknown_fact_ids(
+ tmp_path: Path,
+) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ input_id = candidate["inputs"][0]["fact_id"]
+ parameter_id = candidate["parameters"][0]["fact_id"]
+ candidate["inputs"].append(deepcopy(candidate["inputs"][0]))
+ candidate["parameters"] = []
+ candidate["outputs"][0]["fact_id"] = "output:file:999"
+
+ errors = model.validate_semantic_candidate(facts, candidate)
+
+ assert any(
+ error["code"] == "duplicate_fact_id" and input_id in error["message"]
+ for error in errors
+ )
+ assert any(
+ error["code"] == "missing_fact_id" and parameter_id in error["message"]
+ for error in errors
+ )
+ assert any(
+ error["code"] == "unknown_fact_id" and "output:file:999" in error["message"]
+ for error in errors
+ )
+
+
+def test_candidate_rejects_fields_that_could_rewrite_facts(tmp_path: Path) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ candidate["original_command"] = "rm -rf result.txt"
+ candidate["inputs"][0]["path"] = "invented.fa"
+ candidate["parameters"][0]["flag"] = "--invented"
+ candidate["outputs"][0]["path"] = "invented.out"
+
+ errors = model.validate_semantic_candidate(facts, candidate)
+ fields = {error["field"] for error in errors if error["code"] == "unknown_field"}
+
+ assert fields == {
+ "original_command",
+ "inputs[0].path",
+ "parameters[0].flag",
+ "outputs[0].path",
+ }
+ with pytest.raises(model.SemanticCandidateError) as raised:
+ model.merge_semantics(facts, candidate)
+ assert raised.value.errors == errors
+
+
+def test_semantic_revision_does_not_mutate_execution_facts(tmp_path: Path) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ original_facts = deepcopy(facts)
+ candidate = _candidate(facts)
+ revised = deepcopy(candidate)
+ revised["inputs"][0]["description"] = "A user-selected sequence file."
+ revised["inputs"][0]["replaceability"] = "fixed_resource"
+ revised["outputs"][0]["result_role"] = "intermediate"
+
+ merged = model.merge_semantics(facts, revised)
+
+ assert facts == original_facts
+ assert merged["execution_facts"] == original_facts
+ assert merged["execution_facts"]["facts_sha256"] == facts["facts_sha256"]
+ assert merged["semantics"] == revised
+
+
+@pytest.mark.parametrize(
+ ("section", "field", "bad_value"),
+ [
+ ("inputs", "replaceability", "sometimes"),
+ ("outputs", "result_role", "pretty"),
+ ],
+)
+def test_candidate_rejects_unknown_semantic_enum_values(
+ tmp_path: Path,
+ section: str,
+ field: str,
+ bad_value: str,
+) -> None:
+ model = _semantic_model()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ candidate[section][0][field] = bad_value
+
+ errors = model.validate_semantic_candidate(facts, candidate)
+
+ assert any(error["code"] == "invalid_value" for error in errors)
+
+
+def test_candidate_accepts_generated_for_step_output_reused_as_input(
+ tmp_path: Path,
+) -> None:
+ model = _semantic_model()
+ facts = _facts_with_generated_input(tmp_path)
+ inputs = []
+ parameters = []
+ for command in facts["commands"]:
+ for item in command["inputs"]:
+ inputs.append(
+ {
+ "fact_id": item["fact_id"],
+ "role": "sequence file",
+ "description": "Sequence data consumed by this step.",
+ "replaceability": (
+ "generated"
+ if item["source"] == "generated_input"
+ else "replaceable"
+ ),
+ }
+ )
+ for item in command["parameters"]:
+ parameters.append(
+ {
+ "fact_id": item["fact_id"],
+ "display_name": "output filename",
+ "description": "Output filename fixed by the original command.",
+ }
+ )
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy a sequence file through an intermediate file.",
+ "inputs": inputs,
+ "parameters": parameters,
+ "outputs": [
+ {
+ "fact_id": item["fact_id"],
+ "description": "Observed file produced by the command.",
+ "result_role": "final",
+ }
+ for item in facts["outputs"]["files"]
+ ],
+ }
+
+ assert any(item["replaceability"] == "generated" for item in inputs)
+ assert model.validate_semantic_candidate(facts, candidate) == []
+
+
+def test_candidate_rejects_replaceability_that_conflicts_with_input_source(
+ tmp_path: Path,
+) -> None:
+ model = _semantic_model()
+ facts = _facts_with_generated_input(tmp_path)
+ inputs = []
+ parameters = []
+ sources = {}
+ for command in facts["commands"]:
+ for item in command["inputs"]:
+ sources[item["fact_id"]] = item["source"]
+ inputs.append(
+ {
+ "fact_id": item["fact_id"],
+ "role": "sequence file",
+ "description": "Sequence data consumed by this step.",
+ "replaceability": (
+ "generated"
+ if item["source"] == "generated_input"
+ else "replaceable"
+ ),
+ }
+ )
+ for item in command["parameters"]:
+ parameters.append(
+ {
+ "fact_id": item["fact_id"],
+ "display_name": "output filename",
+ "description": "Output filename fixed by the original command.",
+ }
+ )
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy a sequence file through an intermediate file.",
+ "inputs": inputs,
+ "parameters": parameters,
+ "outputs": [
+ {
+ "fact_id": item["fact_id"],
+ "description": "Observed file produced by the command.",
+ "result_role": "final",
+ }
+ for item in facts["outputs"]["files"]
+ ],
+ }
+
+ generated_index = next(
+ index
+ for index, item in enumerate(candidate["inputs"])
+ if sources[item["fact_id"]] == "generated_input"
+ )
+ external_index = next(
+ index
+ for index, item in enumerate(candidate["inputs"])
+ if sources[item["fact_id"]] != "generated_input"
+ )
+ candidate["inputs"][generated_index]["replaceability"] = "replaceable"
+ candidate["inputs"][external_index]["replaceability"] = "generated"
+
+ errors = model.validate_semantic_candidate(facts, candidate)
+
+ assert {
+ error["field"]
+ for error in errors
+ if error["code"] == "input_source_mismatch"
+ } == {
+ f"inputs[{generated_index}].replaceability",
+ f"inputs[{external_index}].replaceability",
+ }
+
+
+def test_system_binding_makes_invalid_model_references_repairable(
+ tmp_path: Path,
+) -> None:
+ model = _semantic_model()
+ facts = _facts_with_generated_input(tmp_path)
+ inputs = []
+ parameters = []
+ for command in facts["commands"]:
+ for item in command["inputs"]:
+ inputs.append(
+ {
+ "fact_id": item["fact_id"],
+ "role": "sequence file",
+ "description": "Sequence data consumed by this step.",
+ "replaceability": "replaceable",
+ }
+ )
+ for item in command["parameters"]:
+ parameters.append(
+ {
+ "fact_id": item["fact_id"],
+ "display_name": "output filename",
+ "description": "Output filename fixed by the original command.",
+ }
+ )
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy a sequence file through an intermediate file.",
+ "inputs": inputs + [deepcopy(inputs[0])],
+ "parameters": parameters,
+ "outputs": [
+ {
+ "fact_id": "step_001:parameter:002",
+ "description": "Wrongly referenced output.",
+ "result_role": "final",
+ }
+ ],
+ }
+
+ bound, audit = model.bind_fact_derived_semantics(facts, candidate)
+
+ generated_ids = {
+ item["fact_id"]
+ for command in facts["commands"]
+ for item in command["inputs"]
+ if item["source"] == "generated_input"
+ }
+ assert all(
+ item["replaceability"] == "generated"
+ for item in bound["inputs"]
+ if item["fact_id"] in generated_ids
+ )
+ assert len(bound["inputs"]) == len(inputs)
+ assert bound["outputs"] == []
+ assert {item["action"] for item in audit} == {
+ "bind_generated_input",
+ "discard_duplicate_reference",
+ "discard_unknown_reference",
+ }
+ errors = model.validate_semantic_candidate(facts, bound)
+ assert all(error["code"] == "missing_fact_id" for error in errors)
+
+
+def test_system_binding_completes_omitted_locked_fact_rows(tmp_path: Path) -> None:
+ model = _semantic_model()
+ facts = _facts_with_generated_input(tmp_path)
+ input_fact = facts["commands"][1]["inputs"][0]
+ parameter_fact = facts["commands"][1]["parameters"][0]
+ output_fact = facts["outputs"]["files"][1]
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "A valid model-provided summary.",
+ "inputs": [
+ {
+ "fact_id": facts["commands"][0]["inputs"][0]["fact_id"],
+ "role": "model-provided input role",
+ "description": "Model-provided input description.",
+ "replaceability": "fixed_resource",
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": facts["commands"][0]["parameters"][0]["fact_id"],
+ "display_name": "Model-provided parameter name",
+ "description": "Model-provided parameter description.",
+ }
+ ],
+ "outputs": [
+ {
+ "fact_id": facts["outputs"]["files"][0]["fact_id"],
+ "description": "Model-provided output description.",
+ "result_role": "final",
+ }
+ ],
+ }
+ provided_rows = {
+ section: deepcopy(candidate[section])
+ for section in ("inputs", "parameters", "outputs")
+ }
+ original_candidate = deepcopy(candidate)
+
+ bound, audit = model.bind_fact_derived_semantics(facts, candidate)
+
+ assert candidate == original_candidate
+ assert bound["inputs"] == provided_rows["inputs"] + [
+ {
+ "fact_id": input_fact["fact_id"],
+ "role": f"Locked input: {input_fact['path']}.",
+ "description": f"Observed input file: {input_fact['path']}.",
+ "replaceability": "generated",
+ },
+ ]
+ assert bound["parameters"] == provided_rows["parameters"] + [
+ {
+ "fact_id": parameter_fact["fact_id"],
+ "display_name": f"Locked parameter: {parameter_fact['value']}.",
+ "description": (
+ f"Observed command parameter: {parameter_fact['value']}."
+ ),
+ }
+ ]
+ assert bound["outputs"] == provided_rows["outputs"] + [
+ {
+ "fact_id": output_fact["fact_id"],
+ "description": f"Observed output file: {output_fact['path']}.",
+ "result_role": "unknown",
+ }
+ ]
+ assert audit == [
+ {
+ "action": "complete_missing_semantic_row",
+ "section": "inputs",
+ "fact_id": input_fact["fact_id"],
+ },
+ {
+ "action": "complete_missing_semantic_row",
+ "section": "parameters",
+ "fact_id": parameter_fact["fact_id"],
+ },
+ {
+ "action": "complete_missing_semantic_row",
+ "section": "outputs",
+ "fact_id": output_fact["fact_id"],
+ },
+ ]
+ assert model.validate_semantic_candidate(facts, bound) == []
diff --git a/tests/unit/test_extension_stage_lightweight_semantic_repair.py b/tests/unit/test_extension_stage_lightweight_semantic_repair.py
new file mode 100644
index 000000000..c2428b6e1
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_semantic_repair.py
@@ -0,0 +1,477 @@
+from __future__ import annotations
+
+from copy import deepcopy
+import importlib
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from gfa_core.extension_stage_lightweight.converter import build_execution_facts
+from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
+from gfa_core.extension_stage_lightweight.semantic_model import (
+ validate_semantic_candidate,
+)
+
+
+def _module():
+ return importlib.import_module(
+ "gfa_core.extension_stage_lightweight.semantic_repair"
+ )
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _facts(tmp_path: Path) -> dict:
+ raw_code = tmp_path / "raw_code"
+ raw_data = tmp_path / "raw_test_data"
+ _write(raw_code, "run.sh", "cp model.hmm result.txt\n")
+ _write(raw_data, "model.hmm", "HMMER3/f\n")
+ evidence = run_raw_baseline(
+ command="bash run.sh",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ run_dir=tmp_path / "baseline",
+ )
+ return build_execution_facts(evidence)
+
+
+def _candidate(facts: dict) -> dict:
+ command = facts["commands"][0]
+ return {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Copy an HMM model to an observed result file.",
+ "inputs": [
+ {
+ "fact_id": command["inputs"][0]["fact_id"],
+ "role": "hmm_model",
+ "description": "Uploaded HMM model.",
+ "replaceability": "replaceable",
+ }
+ ],
+ "parameters": [
+ {
+ "fact_id": command["parameters"][0]["fact_id"],
+ "display_name": "result file",
+ "description": "Output filename from the original command.",
+ }
+ ],
+ "outputs": [
+ {
+ "fact_id": facts["outputs"]["files"][0]["fact_id"],
+ "description": "Observed copied result.",
+ "result_role": "final",
+ }
+ ],
+ }
+
+
+def _patch(facts: dict, *, section: str, fact_id: str, fields: dict) -> dict:
+ return {
+ "facts_sha256": facts["facts_sha256"],
+ "changes": [
+ {
+ "section": section,
+ "fact_id": fact_id,
+ "fields": fields,
+ }
+ ],
+ }
+
+
+def test_automatic_repair_binds_generated_input_from_locked_facts() -> None:
+ module = _module()
+ facts = {
+ "facts_sha256": "a" * 64,
+ "commands": [
+ {
+ "inputs": [
+ {"fact_id": "step_001:input:001", "source": "baseline_input"}
+ ],
+ "parameters": [],
+ },
+ {
+ "inputs": [
+ {"fact_id": "step_002:input:001", "source": "generated_input"}
+ ],
+ "parameters": [],
+ },
+ ],
+ "outputs": {
+ "files": [{"fact_id": "output:file:001"}],
+ "stdout": {"present": False},
+ },
+ }
+ candidate = {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Process an uploaded sequence through two steps.",
+ "inputs": [
+ {
+ "fact_id": "step_001:input:001",
+ "role": "uploaded sequence",
+ "description": "User-provided sequence file.",
+ "replaceability": "replaceable",
+ }
+ ],
+ "parameters": [],
+ "outputs": [
+ {
+ "fact_id": "output:file:001",
+ "description": "Final processed sequence.",
+ "result_role": "final",
+ }
+ ],
+ }
+
+ def generator(context: dict, attempt: int):
+ return (
+ _patch(
+ facts,
+ section="inputs",
+ fact_id="step_002:input:001",
+ fields={
+ "role": "intermediate sequence",
+ "description": "Sequence generated by the previous step.",
+ "replaceability": "replaceable",
+ },
+ ),
+ {"request_id": "repair-generated"},
+ )
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ validation_errors=validate_semantic_candidate(facts, candidate),
+ patch_generator=generator,
+ )
+
+ assert repaired["inputs"][1]["replaceability"] == "generated"
+ assert history[0]["status"] == "success"
+ assert history[0]["metadata"]["fact_derived_semantic_bindings"][0][
+ "action"
+ ] == "bind_generated_input"
+
+
+def test_automatic_repair_changes_only_the_failed_field(tmp_path: Path) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ parameter_id = candidate["parameters"][0]["fact_id"]
+ candidate["parameters"][0].pop("description")
+ before = deepcopy(candidate)
+ errors = validate_semantic_candidate(facts, candidate)
+ seen_contexts: list[dict] = []
+
+ def generator(context: dict, attempt: int):
+ seen_contexts.append(context)
+ return (
+ _patch(
+ facts,
+ section="parameters",
+ fact_id=parameter_id,
+ fields={"description": "Output filename from the original command."},
+ ),
+ {"request_id": "repair-1"},
+ )
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ validation_errors=errors,
+ patch_generator=generator,
+ )
+
+ assert candidate == before
+ assert repaired["parameters"][0]["description"].startswith("Output filename")
+ unchanged = deepcopy(repaired)
+ unchanged["parameters"][0].pop("description")
+ assert unchanged == before
+ assert seen_contexts[0]["allowed_targets"] == [
+ {
+ "section": "parameters",
+ "fact_id": parameter_id,
+ "field": "description",
+ }
+ ]
+ assert history == [
+ {
+ "attempt": 1,
+ "mode": "automatic",
+ "status": "success",
+ "diff": [
+ {
+ "section": "parameters",
+ "fact_id": parameter_id,
+ "field": "description",
+ "before": None,
+ "after": "Output filename from the original command.",
+ }
+ ],
+ "metadata": {"request_id": "repair-1"},
+ "errors": [],
+ }
+ ]
+
+
+def test_user_feedback_repairs_semantics_and_records_exact_diff(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ original_facts = deepcopy(facts)
+ candidate = _candidate(facts)
+ original_candidate = deepcopy(candidate)
+ input_id = candidate["inputs"][0]["fact_id"]
+
+ def generator(context: dict, attempt: int):
+ assert context["user_feedback"] == "HMM 文件是固定资源,不是每次替换的输入"
+ return (
+ _patch(
+ facts,
+ section="inputs",
+ fact_id=input_id,
+ fields={
+ "description": "Bundled HMM model used by this analysis.",
+ "replaceability": "fixed_resource",
+ },
+ ),
+ {},
+ )
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ user_feedback="HMM 文件是固定资源,不是每次替换的输入",
+ patch_generator=generator,
+ )
+
+ assert facts == original_facts
+ assert candidate == original_candidate
+ assert repaired["inputs"][0]["replaceability"] == "fixed_resource"
+ assert repaired["parameters"] == original_candidate["parameters"]
+ assert repaired["outputs"] == original_candidate["outputs"]
+ assert {item["field"] for item in history[0]["diff"]} == {
+ "description",
+ "replaceability",
+ }
+ assert history[0]["mode"] == "user_feedback"
+
+
+def test_automatic_repair_rejects_unrelated_semantic_changes(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ parameter_id = candidate["parameters"][0]["fact_id"]
+ input_id = candidate["inputs"][0]["fact_id"]
+ candidate["parameters"][0].pop("description")
+ original = deepcopy(candidate)
+
+ def generator(context: dict, attempt: int):
+ return (
+ {
+ "facts_sha256": facts["facts_sha256"],
+ "changes": [
+ {
+ "section": "parameters",
+ "fact_id": parameter_id,
+ "fields": {"description": "Correct repair."},
+ },
+ {
+ "section": "inputs",
+ "fact_id": input_id,
+ "fields": {"description": "Unrequested rewrite."},
+ },
+ ],
+ },
+ {},
+ )
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ validation_errors=validate_semantic_candidate(facts, candidate),
+ patch_generator=generator,
+ max_attempts=1,
+ )
+
+ assert repaired == original
+ assert history[-1]["status"] == "needs_user_confirmation"
+ assert any("not allowed" in error["message"] for error in history[-1]["errors"])
+
+
+def test_repair_rejects_fact_rewrites_and_unknown_ids(tmp_path: Path) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ original = deepcopy(candidate)
+
+ invalid_patches = [
+ {
+ "facts_sha256": facts["facts_sha256"],
+ "original_command": "invented command",
+ "changes": [],
+ },
+ _patch(
+ facts,
+ section="inputs",
+ fact_id="step_999:input:999",
+ fields={"description": "Invented input."},
+ ),
+ _patch(
+ facts,
+ section="inputs",
+ fact_id=candidate["inputs"][0]["fact_id"],
+ fields={"path": "invented.fa", "flag": "--invented"},
+ ),
+ ]
+
+ for invalid in invalid_patches:
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ user_feedback="revise this",
+ patch_generator=lambda context, attempt, patch=invalid: (patch, {}),
+ max_attempts=1,
+ )
+ assert repaired == original
+ assert history[-1]["status"] == "needs_user_confirmation"
+ assert history[-1]["errors"]
+
+
+def test_repair_stops_after_twenty_invalid_attempts(tmp_path: Path) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ calls: list[int] = []
+
+ def generator(context: dict, attempt: int):
+ calls.append(attempt)
+ return ({"facts_sha256": "wrong", "changes": []}, {})
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ user_feedback="revise this",
+ patch_generator=generator,
+ max_attempts=100,
+ )
+
+ assert repaired == candidate
+ assert calls == list(range(1, 21))
+ assert len(history) == 20
+ assert history[-1]["status"] == "needs_user_confirmation"
+ assert history[-1]["errors"]
+
+
+def test_default_deepseek_generator_requests_only_an_allowed_patch(
+ tmp_path: Path,
+) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ parameter_id = candidate["parameters"][0]["fact_id"]
+ candidate["parameters"][0].pop("description")
+ patch = _patch(
+ facts,
+ section="parameters",
+ fact_id=parameter_id,
+ fields={"description": "Output filename from the original command."},
+ )
+ api_patch = deepcopy(patch)
+ api_patch.pop("facts_sha256")
+ requests: list[dict] = []
+
+ class FakeCompletions:
+ def create(self, **kwargs):
+ requests.append(kwargs)
+ return SimpleNamespace(
+ id="repair-request-1",
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(content=json.dumps(api_patch))
+ )
+ ],
+ usage=SimpleNamespace(
+ prompt_tokens=30,
+ completion_tokens=20,
+ total_tokens=50,
+ ),
+ )
+
+ fake_client = SimpleNamespace(
+ chat=SimpleNamespace(completions=FakeCompletions())
+ )
+ env_file = _write(
+ tmp_path,
+ ".env",
+ "NEW_API_KEY=secret-test-key\n"
+ "NEW_API_BASE_URL=https://api.example.test/v1\n"
+ "NEW_API_MODEL=deepseek-test\n",
+ )
+
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ validation_errors=validate_semantic_candidate(facts, candidate),
+ raw_code=tmp_path / "raw_code",
+ environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
+ client_factory=lambda config: fake_client,
+ )
+
+ assert repaired["parameters"][0]["description"].startswith("Output filename")
+ request = requests[0]
+ assert request["temperature"] == 0
+ assert request["max_tokens"] == 6000
+ assert "Return only a semantic patch" in request["messages"][0]["content"]
+ payload = json.loads(request["messages"][1]["content"])
+ assert "facts_sha256" not in payload["required_patch_shape"]
+ assert payload["allowed_targets"] == [
+ {
+ "section": "parameters",
+ "fact_id": parameter_id,
+ "field": "description",
+ }
+ ]
+ assert payload["execution_facts"] == facts
+ assert payload["candidate"] == candidate
+ assert "secret-test-key" not in json.dumps(request)
+ assert history[0]["metadata"]["request_id"] == "repair-request-1"
+
+
+def test_deepseek_repair_error_keeps_auditable_raw_response(tmp_path: Path) -> None:
+ module = _module()
+ facts = _facts(tmp_path)
+ candidate = _candidate(facts)
+ candidate["parameters"][0].pop("description")
+
+ class FakeCompletions:
+ def create(self, **kwargs):
+ return SimpleNamespace(
+ id="invalid-repair-1",
+ choices=[SimpleNamespace(message=SimpleNamespace(content="[]"))],
+ usage=None,
+ )
+
+ env_file = _write(tmp_path, ".env", "NEW_API_KEY=secret-test-key\n")
+ repaired, history = module.repair_semantic_candidate(
+ execution_facts=facts,
+ candidate=candidate,
+ raw_code=tmp_path / "raw_code",
+ environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
+ client_factory=lambda config: SimpleNamespace(
+ chat=SimpleNamespace(completions=FakeCompletions())
+ ),
+ max_attempts=1,
+ )
+
+ assert repaired == candidate
+ assert history[0]["status"] == "needs_user_confirmation"
+ assert history[0]["metadata"]["request_id"] == "invalid-repair-1"
+ assert history[0]["metadata"]["raw_response"] == "[]"
+ assert history[0]["errors"][0]["code"] == "invalid_json"
diff --git a/tests/unit/test_extension_stage_lightweight_service.py b/tests/unit/test_extension_stage_lightweight_service.py
new file mode 100644
index 000000000..c351c8513
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_service.py
@@ -0,0 +1,576 @@
+from __future__ import annotations
+
+from pathlib import Path
+import threading
+import time
+
+import pytest
+
+
+def _write(root: Path, relative: str, content: str) -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _semantic_from_facts(facts: dict) -> dict:
+ return {
+ "facts_sha256": facts["facts_sha256"],
+ "function_summary": "Count FASTA records or copy one FASTA file.",
+ "inputs": [
+ {
+ "fact_id": item["fact_id"],
+ "role": "analysis_input",
+ "description": "Input supplied to the original command.",
+ "replaceability": "generated"
+ if item["source"] == "generated_input"
+ else "replaceable",
+ }
+ for command in facts["commands"]
+ for item in command["inputs"]
+ ],
+ "parameters": [
+ {
+ "fact_id": item["fact_id"],
+ "display_name": item["flag"] or "command value",
+ "description": "Original command parameter.",
+ }
+ for command in facts["commands"]
+ for item in command["parameters"]
+ ],
+ "outputs": [
+ {
+ "fact_id": item["fact_id"],
+ "description": "Observed analysis result.",
+ "result_role": "final",
+ }
+ for item in facts["outputs"]["files"]
+ ]
+ + (
+ [
+ {
+ "fact_id": facts["outputs"]["stdout"]["fact_id"],
+ "description": "Direct textual result.",
+ "result_role": "final",
+ }
+ ]
+ if facts["outputs"]["stdout"].get("present")
+ else []
+ ),
+ }
+
+
+def _service(tmp_path: Path):
+ from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
+
+ return LightweightExtensionService(
+ tmp_path / "store",
+ semantic_generator=lambda facts, _raw_code: (_semantic_from_facts(facts), {}),
+ )
+
+
+def _package(tmp_path: Path, *, stdout_only: bool) -> tuple[Path, Path, str]:
+ raw_code = tmp_path / "source" / "raw_code"
+ raw_data = tmp_path / "source" / "raw_test_data"
+ _write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
+ if stdout_only:
+ _write(raw_code, "count.sh", "grep -c '^>' input.fa\n")
+ return raw_code, raw_data, "bash count.sh"
+ _write(raw_code, "copy.sh", "cp input.fa result.fa\n")
+ return raw_code, raw_data, "bash copy.sh"
+
+
+def _published_copy_service(tmp_path: Path):
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=False)
+ service.upload(
+ extension_id="copy_fasta",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+ service.confirm("copy_fasta", operation_token="confirm-1")
+ service.validate("copy_fasta", operation_token="validate-1")
+ service.review_result("copy_fasta", operation_token="review-1")
+ service.publish("copy_fasta", operation_token="publish-1")
+ return service
+
+
+def test_stdout_lifecycle_records_reviewed_validation_and_publishes(tmp_path: Path) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+
+ created = service.upload(
+ extension_id="count_fasta",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+ assert created["status"] == "confirmation_pending"
+ assert service.get("count_fasta")["execution_facts"]["original_command"] == command
+
+ service.confirm("count_fasta", operation_token="confirm-1")
+ validation = service.validate("count_fasta", operation_token="validate-1")
+ state = service.get("count_fasta")
+ assert validation["status"] == "result_review_pending"
+ assert state["validation_runs"][0]["contract_passed"] is True
+ assert state["validation_runs"][0]["projection"]["stdout"]["present"] is True
+
+ service.review_result("count_fasta", operation_token="review-1")
+ published = service.publish("count_fasta", operation_token="publish-1")
+ assert published["status"] == "published"
+ assert service.get("count_fasta")["published_validation_run_id"] == validation["run_id"]
+ assert service.help("count_fasta")["original_command"] == command
+
+
+def test_published_run_requires_job_context_and_projects_into_job_workspace(
+ tmp_path: Path,
+) -> None:
+ service = _published_copy_service(tmp_path)
+ input_root = tmp_path / "input"
+ workspace_root = tmp_path / "workspace"
+ runtime_input = _write(input_root, "job_1/different_name.fa", ">x\nTTTT\n")
+ _write(workspace_root, "job_1/.keep", "")
+
+ with pytest.raises(ValueError, match="run_job_id"):
+ service.run(
+ "copy_fasta",
+ input_overrides={"input.fa": runtime_input},
+ operation_token="run-missing-context",
+ )
+ with pytest.raises(ValueError, match="selected job"):
+ service.run(
+ "copy_fasta",
+ input_overrides={"input.fa": _write(tmp_path, "outside.fa", ">z\n")},
+ run_job_id="job_1",
+ input_root=input_root,
+ workspace_root=workspace_root,
+ operation_token="run-outside",
+ )
+
+ result = service.run(
+ "copy_fasta",
+ input_overrides={"input.fa": runtime_input},
+ run_job_id="job_1",
+ input_root=input_root,
+ workspace_root=workspace_root,
+ operation_token="run-1",
+ )
+ runtime = service.get("copy_fasta")["runtime_runs"][-1]
+ output = Path(runtime["projection"]["files"][0]["absolute_path"])
+ assert runtime["success"] is True
+ assert output.read_text(encoding="utf-8") == ">x\nTTTT\n"
+ assert output.is_relative_to(
+ workspace_root / "job_1" / "extension_runs" / "copy_fasta"
+ )
+
+
+def test_published_run_rejects_symlink_escape_from_selected_job(tmp_path: Path) -> None:
+ service = _published_copy_service(tmp_path)
+ input_root = tmp_path / "input"
+ workspace_root = tmp_path / "workspace"
+ outside = _write(input_root, "job_2/secret.fa", ">secret\n")
+ link = input_root / "job_1/link.fa"
+ link.parent.mkdir(parents=True, exist_ok=True)
+ link.symlink_to(outside)
+ _write(workspace_root, "job_1/.keep", "")
+
+ with pytest.raises(ValueError, match="selected job"):
+ service.run(
+ "copy_fasta",
+ input_overrides={"input.fa": link},
+ run_job_id="job_1",
+ input_root=input_root,
+ workspace_root=workspace_root,
+ operation_token="run-symlink-escape",
+ )
+
+
+def test_fact_dispute_requires_rebuild_and_duplicate_token_uses_one_run(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(
+ extension_id="count_fasta",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+ disputed = service.revise(
+ "count_fasta",
+ feedback="The original input path is wrong.",
+ revision_target="execution_facts.path",
+ operation_token="revise-1",
+ )
+ assert disputed["status"] == "rebuild_required"
+ with pytest.raises(ValueError, match="rebuild"):
+ service.confirm("count_fasta", operation_token="confirm-1")
+ service.rebuild("count_fasta", operation_token="rebuild-1")
+ service.confirm("count_fasta", operation_token="confirm-2")
+ first = service.validate("count_fasta", operation_token="validate-once")
+ second = service.validate("count_fasta", operation_token="validate-once")
+ assert first == second
+ assert len(service.get("count_fasta")["validation_runs"]) == 1
+
+
+def test_concurrent_same_token_starts_validation_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(
+ extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
+ original_command=command, operation_token="upload-1",
+ )
+ service.confirm("count_fasta", operation_token="confirm-1")
+ real_execute = service._execute
+ entered = threading.Event()
+ release = threading.Event()
+ calls = 0
+
+ def slow_execute(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ entered.set()
+ assert release.wait(timeout=3)
+ return real_execute(*args, **kwargs)
+
+ monkeypatch.setattr(service, "_execute", slow_execute)
+ results: list[dict] = []
+ first = threading.Thread(
+ target=lambda: results.append(service.validate("count_fasta", operation_token="once"))
+ )
+ first.start()
+ assert entered.wait(timeout=3)
+ second = threading.Thread(
+ target=lambda: results.append(service.validate("count_fasta", operation_token="once"))
+ )
+ second.start()
+ second.join(timeout=3)
+ release.set()
+ first.join(timeout=3)
+ assert calls == 1
+ assert {result["run_id"] for result in results} == {results[0]["run_id"]}
+
+
+def test_stop_can_use_persisted_process_without_waiting_for_long_baseline(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ raw_code = tmp_path / "source" / "raw_code"
+ raw_data = tmp_path / "source" / "raw_test_data"
+ raw_code.mkdir(parents=True)
+ raw_data.mkdir(parents=True)
+ _write(raw_code, "sleep.sh", "sleep 10\nprintf 'late result\\n'\n")
+ outcome: list[dict] = []
+ thread = threading.Thread(
+ target=lambda: outcome.append(
+ service.upload(
+ extension_id="slow", raw_code=raw_code, raw_data=raw_data,
+ original_command="bash sleep.sh", operation_token="upload-1",
+ )
+ )
+ )
+ thread.start()
+ deadline = time.monotonic() + 3
+ while time.monotonic() < deadline:
+ try:
+ state = service.get("slow")
+ except ValueError:
+ time.sleep(0.02)
+ continue
+ if state.get("active_process"):
+ break
+ time.sleep(0.02)
+ assert service.get("slow")["active_process"]["operation_token"] == "upload-1"
+ stopped = service.stop("slow", operation_token="upload-1")
+ thread.join(timeout=5)
+ assert stopped["stopped"] is True
+ assert not service.get("slow").get("active_process")
+ assert outcome[0]["status"] == "baseline_failed"
+
+
+def test_initial_semantic_failure_is_persisted_and_rebuildable(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
+
+ failing = LightweightExtensionService(
+ tmp_path / "store",
+ semantic_generator=lambda _facts, _raw_code: (_ for _ in ()).throw(RuntimeError("semantic down")),
+ )
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ first = failing.upload(
+ extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
+ original_command=command, operation_token="upload-1",
+ )
+ assert first["status"] == "build_failed"
+ assert failing.get("count_fasta")["error"]["phase"] == "semantic_candidate"
+ failing.semantic_generator = lambda facts, _raw_code: (_semantic_from_facts(facts), {})
+ rebuilt = failing.rebuild("count_fasta", operation_token="rebuild-1")
+ assert rebuilt["status"] == "confirmation_pending"
+
+
+def test_semantic_retry_reuses_baseline_and_locked_execution_facts(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
+
+ calls = 0
+
+ def semantic_generator(facts: dict, _raw_code: Path):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise RuntimeError("truncated semantic JSON")
+ return _semantic_from_facts(facts), {"attempt": calls}
+
+ service = LightweightExtensionService(
+ tmp_path / "store", semantic_generator=semantic_generator
+ )
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ first = service.upload(
+ extension_id="count_retry",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+ failed = service.get("count_retry")
+ baseline_run_dir = failed["baseline"]["run_dir"]
+ facts_sha256 = failed["execution_facts"]["facts_sha256"]
+
+ retried = service.retry_semantic("count_retry", operation_token="semantic-2")
+ record = service.get("count_retry")
+
+ assert first["status"] == "build_failed"
+ assert retried["status"] == "confirmation_pending"
+ assert calls == 2
+ assert record["baseline"]["run_dir"] == baseline_run_dir
+ assert record["execution_facts"]["facts_sha256"] == facts_sha256
+ assert record["semantic_metadata"] == {"attempt": 2}
+
+
+def test_semantic_retry_recovers_facts_from_saved_legacy_baseline(tmp_path: Path) -> None:
+ from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
+
+ service = LightweightExtensionService(
+ tmp_path / "store",
+ semantic_generator=lambda _facts, _raw_code: (_ for _ in ()).throw(
+ RuntimeError("semantic down")
+ ),
+ )
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(
+ extension_id="legacy_failure",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+ legacy = service.get("legacy_failure")
+ baseline_run_dir = legacy["baseline"]["run_dir"]
+ legacy.pop("execution_facts")
+ service._write_record("legacy_failure", legacy)
+ service.semantic_generator = lambda facts, _raw_code: (
+ _semantic_from_facts(facts),
+ {},
+ )
+
+ retried = service.retry_semantic(
+ "legacy_failure", operation_token="semantic-legacy"
+ )
+ record = service.get("legacy_failure")
+
+ assert retried["status"] == "confirmation_pending"
+ assert record["baseline"]["run_dir"] == baseline_run_dir
+ assert record["execution_facts"]["facts_sha256"]
+
+
+def test_semantic_retry_is_noop_after_candidate_already_succeeded(
+ tmp_path: Path,
+) -> None:
+ calls = 0
+
+ def semantic_generator(facts: dict, _raw_code: Path):
+ nonlocal calls
+ calls += 1
+ return _semantic_from_facts(facts), {}
+
+ from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
+
+ service = LightweightExtensionService(
+ tmp_path / "store", semantic_generator=semantic_generator
+ )
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(
+ extension_id="already_ready",
+ raw_code=raw_code,
+ raw_data=raw_data,
+ original_command=command,
+ operation_token="upload-1",
+ )
+
+ retried = service.retry_semantic(
+ "already_ready", operation_token="semantic-noop"
+ )
+
+ assert retried["status"] == "confirmation_pending"
+ assert calls == 1
+ assert service.get("already_ready")["status"] == "confirmation_pending"
+
+
+def test_operation_records_are_small_references_not_record_snapshots(tmp_path: Path) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(
+ extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
+ original_command=command, operation_token="upload-1",
+ )
+ operation = service.get("count_fasta")["operations"]["upload-1"]
+ assert set(operation) <= {"action", "state", "response", "run_id"}
+ assert "baseline" not in operation["response"]
+
+
+def test_unresolved_binding_finishes_validation_without_leaving_running(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
+ service.confirm("count_fasta", operation_token="confirm-1")
+ monkeypatch.setattr(
+ service,
+ "_execute",
+ lambda *_args, run_id, **_kwargs: {
+ "run_id": run_id,
+ "success": False,
+ "contract_passed": False,
+ "binding": {"status": "blocked"},
+ "projection": {"files": [], "stdout": {"present": False}},
+ },
+ )
+
+ response = service.validate("count_fasta", operation_token="validate-1")
+
+ assert response["status"] == "validation_failed"
+ state = service.get("count_fasta")
+ assert state["active_process"] is None
+ assert state["operations"]["validate-1"]["state"] == "completed"
+
+
+def test_early_stop_is_recorded_before_identity_and_honoured_on_callback(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ import gfa_core.extension_stage_lightweight.service as service_module
+
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
+ service.confirm("count_fasta", operation_token="confirm-1")
+ entered = threading.Event()
+ release = threading.Event()
+ monkeypatch.setattr(service, "_execute", lambda *_args, run_id, **_kwargs: (entered.set(), release.wait(3), {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}})[2])
+ stopped: list[dict] = []
+ monkeypatch.setattr(service_module, "stop_process_group", lambda identity, **_kwargs: stopped.append(identity) or True)
+ worker = threading.Thread(target=lambda: service.validate("count_fasta", operation_token="validate-1"))
+ worker.start()
+ assert entered.wait(timeout=3)
+
+ pending = service.stop("count_fasta", operation_token="validate-1")
+ assert pending["stopped"] is False
+ assert service.get("count_fasta")["operations"]["validate-1"]["stop_requested"] is True
+ identity = {"pid": 11, "pgid": 11, "pid_start_time": 1, "supervisor_token": "x"}
+ service._mark_process("count_fasta", token="validate-1", phase="validation", identity=identity)
+ release.set()
+ worker.join(timeout=3)
+
+ assert stopped == [identity]
+ assert service.get("count_fasta")["active_process"] is None
+
+
+def test_run_rejects_unsafe_job_id_before_path_resolution(tmp_path: Path) -> None:
+ service = _published_copy_service(tmp_path)
+ input_root = tmp_path / "input"
+ workspace_root = tmp_path / "workspace"
+ _write(input_root, "job_1/input.fa", ">x\n")
+ _write(workspace_root, "job_1/.keep", "")
+
+ with pytest.raises(ValueError, match="run_job_id"):
+ service.run(
+ "copy_fasta", input_overrides={"input.fa": input_root / "job_1/input.fa"},
+ run_job_id="../job_1", input_root=input_root, workspace_root=workspace_root,
+ operation_token="run-1",
+ )
+
+
+def test_validation_failure_can_retry_or_rebuild(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
+ service.confirm("count_fasta", operation_token="confirm-1")
+ actual = service._execute
+ failed = False
+
+ def fail_once(*args, run_id, **kwargs):
+ nonlocal failed
+ if not failed:
+ failed = True
+ return {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}}
+ return actual(*args, run_id=run_id, **kwargs)
+
+ monkeypatch.setattr(service, "_execute", fail_once)
+ assert service.validate("count_fasta", operation_token="validate-fail")["status"] == "validation_failed"
+ assert service.validate("count_fasta", operation_token="validate-retry")["status"] == "result_review_pending"
+
+ rebuilder = _service(tmp_path / "rebuild-case")
+ rebuild_code, rebuild_data, rebuild_command = _package(tmp_path / "rebuild-case", stdout_only=True)
+ rebuilder.upload(extension_id="count_fasta", raw_code=rebuild_code, raw_data=rebuild_data, original_command=rebuild_command, operation_token="upload-1")
+ rebuilder.confirm("count_fasta", operation_token="confirm-1")
+ monkeypatch.setattr(rebuilder, "_execute", lambda *_args, run_id, **_kwargs: {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}})
+ assert rebuilder.validate("count_fasta", operation_token="validate-fail")["status"] == "validation_failed"
+ assert rebuilder.rebuild("count_fasta", operation_token="rebuild-1")["status"] == "confirmation_pending"
+
+
+def test_concurrent_duplicate_upload_token_reuses_reserved_operation(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+ actual = service._run_baseline_operation
+ entered = threading.Event()
+ release = threading.Event()
+
+ def slow_baseline(*args, **kwargs):
+ entered.set()
+ assert release.wait(timeout=3)
+ return actual(*args, **kwargs)
+
+ monkeypatch.setattr(service, "_run_baseline_operation", slow_baseline)
+ results: list[dict] = []
+ first = threading.Thread(target=lambda: results.append(service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")))
+ first.start()
+ assert entered.wait(timeout=3)
+ second = threading.Thread(target=lambda: results.append(service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")))
+ second.start()
+ second.join(timeout=3)
+ release.set()
+ first.join(timeout=3)
+
+ assert len(results) == 2
+ assert {item["run_id"] for item in results} == {results[0]["run_id"]}
+
+
+@pytest.mark.parametrize("token", ["", " "])
+def test_upload_rejects_blank_token_before_creating_extension_directory(
+ tmp_path: Path, token: str
+) -> None:
+ service = _service(tmp_path)
+ raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
+
+ with pytest.raises(ValueError, match="operation_token"):
+ service.upload(
+ extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
+ original_command=command, operation_token=token,
+ )
+
+ assert not service.extension_dir("count_fasta").exists()
diff --git a/tests/unit/test_extension_stage_lightweight_shell_binding.py b/tests/unit/test_extension_stage_lightweight_shell_binding.py
new file mode 100644
index 000000000..3f118e301
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_shell_binding.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+
+from gfa_core.extension_stage_lightweight.command_model import build_command_model
+
+
+def _state(*paths: str) -> dict[str, dict[str, object]]:
+ return {path: {"kind": "file", "size": 1, "sha256": path} for path in paths}
+
+
+def test_exact_output_reference_wins_over_prefix_match(tmp_path: Path) -> None:
+ (tmp_path / "run.sh").write_text(
+ """tool --out result.tbl input.dat
+filter result.tbl > result.tbl.filtered
+""",
+ encoding="utf-8",
+ )
+
+ model = build_command_model(
+ command="bash run.sh",
+ work_dir=tmp_path,
+ before_files=_state("run.sh", "input.dat"),
+ output_files=["result.tbl", "result.tbl.filtered"],
+ code_files=["run.sh"],
+ )
+
+ assert model["commands"][0]["outputs"] == ["result.tbl"]
+ assert model["commands"][1]["outputs"] == ["result.tbl.filtered"]
+
+
+def test_shell_positional_argument_is_bound_through_simple_variable(tmp_path: Path) -> None:
+ (tmp_path / "count.sh").write_text(
+ """#!/usr/bin/env bash
+set -euo pipefail
+if [ "$#" -ne 1 ]; then echo usage >&2; exit 1; fi
+input=$1
+awk '/^>/{n++} END{print n+0}' "$input"
+""",
+ encoding="utf-8",
+ )
+
+ model = build_command_model(
+ command="bash count.sh Ath.genome.fasta",
+ work_dir=tmp_path,
+ before_files=_state("count.sh", "Ath.genome.fasta"),
+ output_files=[],
+ code_files=["count.sh"],
+ )
+
+ assert [command["tool"] for command in model["commands"]] == ["awk"]
+ assert model["commands"][0]["inputs"] == [
+ {
+ "path": "Ath.genome.fasta",
+ "source": "baseline_input",
+ "flag": None,
+ "token_index": 2,
+ }
+ ]
diff --git a/tests/unit/test_extension_stage_lightweight_shell_pipeline.py b/tests/unit/test_extension_stage_lightweight_shell_pipeline.py
new file mode 100644
index 000000000..40dfac85c
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_shell_pipeline.py
@@ -0,0 +1,41 @@
+from pathlib import Path
+
+from gfa_core.extension_stage_lightweight.command_model import build_command_model
+
+
+def test_shell_pipeline_is_modelled_as_separate_executables(tmp_path: Path) -> None:
+ (tmp_path / "run.sh").write_text(
+ """hmmsearch --cut_tc --domtblout hits.tbl model.hmm proteins.fa
+awk '$7<0.01 {print $0}' hits.tbl > filtered.tbl
+awk '{print $1}' filtered.tbl | sort -u > ids.txt
+""",
+ encoding="utf-8",
+ )
+ before = {
+ name: {"kind": "file", "size": 1, "sha256": name}
+ for name in ("run.sh", "model.hmm", "proteins.fa")
+ }
+
+ model = build_command_model(
+ command="bash run.sh",
+ work_dir=tmp_path,
+ before_files=before,
+ output_files=["hits.tbl", "filtered.tbl", "ids.txt"],
+ code_files=["run.sh"],
+ )
+
+ assert [command["tool"] for command in model["commands"]] == [
+ "hmmsearch",
+ "awk",
+ "awk",
+ "sort",
+ ]
+ assert model["commands"][1]["inputs"][0]["path"] == "hits.tbl"
+ assert model["commands"][2]["inputs"][0]["path"] == "filtered.tbl"
+ assert model["commands"][3]["parameters"][0] == {
+ "kind": "switch",
+ "flag": "-u",
+ "value": "-u",
+ "token_index": 1,
+ }
+ assert model["commands"][3]["outputs"] == ["ids.txt"]
diff --git a/tests/unit/test_extension_stage_lightweight_target_launchers.py b/tests/unit/test_extension_stage_lightweight_target_launchers.py
new file mode 100644
index 000000000..be63e6a3b
--- /dev/null
+++ b/tests/unit/test_extension_stage_lightweight_target_launchers.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+WEB = ROOT / "start_plantai_9000.sh"
+WORKER = ROOT / "start_celery_9000.sh"
+
+
+def test_target_launchers_share_lightweight_extension_runtime_roots() -> None:
+ web = WEB.read_text(encoding="utf-8")
+ worker = WORKER.read_text(encoding="utf-8")
+
+ for name in (
+ "BBAV5_PROJECT_ENV_FILE",
+ "BBAV5_LIGHTWEIGHT_EXTENSION_STORE",
+ "BBAV5_LIGHTWEIGHT_INPUT",
+ "BBAV5_LIGHTWEIGHT_WORKSPACE",
+ ):
+ assert name in web
+ assert name in worker
+
+ assert 'export BBAV5_QUEUE_PREFIX="plantai_dev_9000"' in web
+ assert 'export BBAV5_REDIS_URL="redis://localhost:6379/10"' in web
+ assert 'BBAV5_QUEUE_PREFIX="plantai_dev_9000"' in worker
+ assert 'BBAV5_REDIS_URL="redis://localhost:6379/10"' in worker
diff --git a/tests/unit/test_frontend_cpu_monitor_contract.py b/tests/unit/test_frontend_cpu_monitor_contract.py
new file mode 100644
index 000000000..1d1e8556b
--- /dev/null
+++ b/tests/unit/test_frontend_cpu_monitor_contract.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+
+
+INDEX_HTML = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "index.html"
+
+
+def test_cpu_monitor_polls_lightweight_resource_endpoint_independently():
+ source = INDEX_HTML.read_text(encoding="utf-8")
+
+ assert "const refreshResourceUsage = async () =>" in source
+ assert "fetch('/api/resource/status', { cache: 'no-store' })" in source
+ assert "resourceMonitorTimer = setInterval(refreshResourceUsage, 1000)" in source
+ assert "clearInterval(resourceMonitorTimer)" in source
diff --git a/tests/unit/test_frontend_execution_indicator_recovery.py b/tests/unit/test_frontend_execution_indicator_recovery.py
new file mode 100644
index 000000000..e8f45dc00
--- /dev/null
+++ b/tests/unit/test_frontend_execution_indicator_recovery.py
@@ -0,0 +1,40 @@
+from pathlib import Path
+
+
+INDEX_HTML = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "index.html"
+MAIN_PY = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "main.py"
+
+
+def test_execution_indicator_recovers_current_job_tasks_from_backend_runtime():
+ source = INDEX_HTML.read_text(encoding="utf-8")
+
+ assert "resource_tasks: []" in source
+ assert "runtimeOverview.resource_tasks = Array.isArray(data.resource_tasks) ? data.resource_tasks : [];" in source
+ assert "const currentJobRuntimeTasks = computed" in source
+ assert "String(task.job_id || '') === currentJobId" in source
+ assert "const executionIndicatorTask = computed" in source
+
+
+def test_execution_indicator_visibility_and_stop_use_recovered_backend_task():
+ source = INDEX_HTML.read_text(encoding="utf-8")
+
+ assert 'v-if="showExecutionIndicator"' in source
+ assert "const showExecutionIndicator = computed" in source
+ assert "executionIndicatorTask.value" in source
+ assert "currentJobRuntimeTasks.value.forEach" in source
+ assert "task.task_id" in source
+
+
+def test_execution_indicator_clears_only_after_backend_task_is_terminal():
+ source = INDEX_HTML.read_text(encoding="utf-8")
+
+ assert "const ACTIVE_RUNTIME_TASK_STATUSES" in source
+ assert "'queued', 'pending', 'running', 'started', 'waiting_resource', 'retry'" in source
+ assert "ACTIVE_RUNTIME_TASK_STATUSES.has" in source
+
+
+def test_runtime_overview_does_not_truncate_active_resource_tasks():
+ source = MAIN_PY.read_text(encoding="utf-8")
+
+ assert 'overview["resource_tasks"] = resource.get("tasks", [])' in source
+ assert 'resource.get("tasks", [])[:20]' not in source
diff --git a/tests/unit/test_gene_family_scope_reference_roots.py b/tests/unit/test_gene_family_scope_reference_roots.py
new file mode 100644
index 000000000..62d27236b
--- /dev/null
+++ b/tests/unit/test_gene_family_scope_reference_roots.py
@@ -0,0 +1,11 @@
+from gfa_core.gene_family_scope import reference_search_roots
+
+
+def test_reference_search_roots_reads_configured_root(monkeypatch, tmp_path) -> None:
+ reference_root = tmp_path / "references"
+ reference_root.mkdir()
+ monkeypatch.setenv("PLANTAI_REF_DATA_ROOT", str(reference_root))
+
+ roots = reference_search_roots(tmp_path / "input")
+
+ assert reference_root.resolve() in roots
diff --git a/tests/unit/test_persistent_conversation_job_isolation.py b/tests/unit/test_persistent_conversation_job_isolation.py
new file mode 100644
index 000000000..661eb8eec
--- /dev/null
+++ b/tests/unit/test_persistent_conversation_job_isolation.py
@@ -0,0 +1,53 @@
+from pathlib import Path
+
+from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
+from gfa_core.mas.persistent_conversation import PersistentConversationStore
+
+
+def _store(tmp_path: Path) -> PersistentConversationStore:
+ repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
+ repository.initialize()
+ return PersistentConversationStore(repository)
+
+
+def test_rebind_to_different_job_creates_empty_conversation_and_preserves_old(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ old_turn = store.begin_turn(
+ session_id="old-session",
+ job_id="job_old",
+ query="旧 job 的问题",
+ client_history=[],
+ )
+
+ new_conversation_id = store.rebind_job(
+ session_id=old_turn.conversation_id,
+ job_id="job_new",
+ )
+
+ assert new_conversation_id != old_turn.conversation_id
+ old_conversation = store.repository.get_conversation(old_turn.conversation_id)
+ new_conversation = store.repository.get_conversation(new_conversation_id)
+ assert old_conversation is not None and old_conversation.job_id == "job_old"
+ assert new_conversation is not None and new_conversation.job_id == "job_new"
+ assert [message.content for message in store.repository.list_recent_messages(old_turn.conversation_id)] == ["旧 job 的问题"]
+ assert store.repository.list_recent_messages(new_conversation_id) == ()
+ ledger = store.repository.get_goal_ledger(new_conversation_id)
+ assert ledger is not None
+ assert ledger.user_terms == {"active_job_id": "job_new"}
+
+
+def test_rebind_to_same_job_keeps_conversation_identity(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ turn = store.begin_turn(
+ session_id="same-session",
+ job_id="job_same",
+ query="当前 job 的问题",
+ client_history=[],
+ )
+
+ conversation_id = store.rebind_job(
+ session_id=turn.conversation_id,
+ job_id="job_same",
+ )
+
+ assert conversation_id == turn.conversation_id
diff --git a/tests/unit/test_result_browser_persistence.py b/tests/unit/test_result_browser_persistence.py
new file mode 100644
index 000000000..f5c969be0
--- /dev/null
+++ b/tests/unit/test_result_browser_persistence.py
@@ -0,0 +1,77 @@
+import asyncio
+import json
+import os
+from pathlib import Path
+
+
+def test_result_browser_prefers_latest_manifest_and_shows_subfamily_evidence(monkeypatch, tmp_path):
+ from apps.web.vue3 import main as web_main
+
+ workspace_root = tmp_path / "workspace"
+ final_dir = workspace_root / "job-results" / "final"
+ final_dir.mkdir(parents=True)
+ image = final_dir / "Target.PEBP.target-Os.ref-At.subfamily_evidence.motif.png"
+ image.write_bytes(b"png")
+
+ success = final_dir / "Target.PEBP.target-Os.ref-At.subfamily_evidence.motif.manifest.json"
+ success.write_text(
+ json.dumps(
+ {
+ "stage": "motif",
+ "status": "success",
+ "prefix": "Os.PEBP",
+ "output_prefix": "Target.PEBP.target-Os.ref-At.subfamily_evidence",
+ "scope_id": "PEBP.target-Os.ref-At.target_plus_reference",
+ "scope_mode": "target_plus_reference",
+ "analysis_route": "subfamily_evidence",
+ "result_group": "motif_subfamily_evidence",
+ "elapsed_sec": 3.0,
+ "artifacts": {
+ "motif_plot_png": {
+ "artifact": "motif_plot",
+ "path": f"final/{image.name}",
+ "type": "image",
+ }
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ failed = final_dir / "Os.PEBP.target_only.motif.manifest.json"
+ failed.write_text(
+ json.dumps(
+ {
+ "stage": "motif",
+ "status": "failed",
+ "prefix": "Os.PEBP",
+ "scope_id": "PEBP.target-Os.target_only",
+ "analysis_route": "multi_species",
+ "parameters": {"error": "old failed attempt"},
+ "artifacts": {},
+ }
+ ),
+ encoding="utf-8",
+ )
+ os.utime(failed, (1, 1))
+ os.utime(success, (2, 2))
+
+ monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
+ result = asyncio.run(web_main.get_results(job_id="job-results", stage="motif"))
+
+ assert result["motif"]["status"] == "success"
+ assert image.name in json.dumps(result["motif"], ensure_ascii=False)
+
+
+def test_result_panel_has_three_persistent_workflow_groups():
+ html = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8")
+
+ assert "RNA-seq" in html
+ assert "基因家族" in html
+ assert "WGS2GWAS(遗传变异与群体分析)" in html
+ assert "const resultGroups = computed" in html
+ assert "const groupStages = stages.filter(stage => resultGroupNameForStage(stage.name) === group.name);" in html
+ assert "wgsPopulationGwasResultRank(a.name) - wgsPopulationGwasResultRank(b.name)" in html
+ assert "name: 'wgs_qc'" in html
+ assert "name: 'wgs_maf_missing_filter'" in html
+ assert "await refreshResults(completedJobId, stageName);" in html
+ assert "await refreshResults(latestConversation.job_id);" in html
diff --git a/tests/unit/test_stage_tool_tree_passthrough.py b/tests/unit/test_stage_tool_tree_passthrough.py
new file mode 100644
index 000000000..e8ef93ce9
--- /dev/null
+++ b/tests/unit/test_stage_tool_tree_passthrough.py
@@ -0,0 +1,62 @@
+import asyncio
+import json
+import sys
+import types
+from dataclasses import dataclass
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor # noqa: E402
+
+
+def test_stage_tool_executor_passes_preflight_tree_to_backend(monkeypatch, tmp_path):
+ captured = {}
+
+ @dataclass
+ class FakeJobInfo:
+ job_id: str
+ input_dir: str
+ workspace_dir: str
+ logs_dir: str
+ created_at: str
+
+ def fake_run_stage(**kwargs):
+ captured.update(kwargs)
+ final_dir = Path(kwargs["job"].workspace_dir) / "final"
+ final_dir.mkdir(parents=True, exist_ok=True)
+ manifest = final_dir / "Target.PEBP.motif.manifest.json"
+ manifest.write_text(
+ json.dumps({"status": "success", "stage": "motif", "prefix": "Target.PEBP"}),
+ encoding="utf-8",
+ )
+ return "motif completed", manifest
+
+ fake_module = types.ModuleType("apps.web.pipeline_backend")
+ fake_module.JobInfo = FakeJobInfo
+ fake_module.run_stage = fake_run_stage
+ monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_module)
+
+ selected_tree = tmp_path / "Target.PEBP.subfamily_evidence.tree.nwk"
+ selected_tree.write_text("(Os1,At1);\n", encoding="utf-8")
+
+ result = asyncio.run(
+ StageToolExecutor(project_root=tmp_path).execute(
+ stage_tool=StageTool(
+ id="motif",
+ name="Motif",
+ script="apps/cli/run_motif.py",
+ category="gene_family",
+ description="test",
+ ),
+ job_id="job_tree_passthrough",
+ prefix="Target.PEBP",
+ extra_params={"treeFile": str(selected_tree), "cpu": 2},
+ )
+ )
+
+ assert result["status"] == "success"
+ assert captured["tree_file"] == str(selected_tree)
diff --git a/tests/unit/test_subfamily_prediction_plan.py b/tests/unit/test_subfamily_prediction_plan.py
index 9e9ffe889..f8f77efad 100755
--- a/tests/unit/test_subfamily_prediction_plan.py
+++ b/tests/unit/test_subfamily_prediction_plan.py
@@ -1,5 +1,13 @@
import importlib.util
+import json
from pathlib import Path
+from types import SimpleNamespace
+
+from gfa_core.gene_family_stage_context import (
+ enrich_stage_params_with_context,
+ infer_analysis_route,
+)
+from gfa_core.mas.artifact_resolver import ArtifactResolver
def _load_vue3_main():
@@ -40,3 +48,285 @@ def test_subfamily_prediction_plan_uses_per_target_evidence_route():
assert detail_by_id["reference_classification"]["params"]["analysisRoute"] == "subfamily_prediction"
assert plan["total_stages"] == 7
assert intent["primary_stage"] == "reference_classification"
+
+
+def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch):
+ main = _load_vue3_main()
+ monkeypatch.setattr(
+ main,
+ "_configured_species_scope",
+ lambda _job_id: {
+ "targetSpecies": ["Os"],
+ "referenceSpecies": ["At"],
+ "comparativeSpecies": [],
+ },
+ )
+ monkeypatch.setattr(
+ main,
+ "_extract_species_codes_from_text_for_job",
+ lambda _job_id, _message: ["Os", "At"],
+ )
+
+ roles = main._resolve_subfamily_request_species_roles(
+ "job-real",
+ "执行目标物种 Os 和参考物种 At 的亚家族分类",
+ {
+ "target_species": ["Os"],
+ "reference_species": ["At"],
+ "params": {"targetSpecies": ["Os"], "referenceSpecies": ["At"]},
+ },
+ )
+
+ assert roles == {
+ "target_species": ["Os"],
+ "reference_species": ["At"],
+ "comparative_species": [],
+ "classification_species": ["Os"],
+ }
+
+
+def test_generic_target_reference_scope_does_not_guess_subfamily_from_stage_name():
+ scope = {
+ "scope_mode": "target_plus_reference",
+ "target_species": ["Ft"],
+ "reference_species": ["At"],
+ "comparative_species": [],
+ }
+
+ assert infer_analysis_route("phylogeny", scope) == "multi_species"
+ assert infer_analysis_route("motif", scope) == "multi_species"
+
+
+def test_newick_manifest_artifact_satisfies_tree_requirement():
+ assert ArtifactResolver._kind_matches("tree", "newick") is True
+
+
+def test_semantic_preflight_materializes_selected_tree(monkeypatch):
+ main = _load_vue3_main()
+
+ class Selected:
+ path = "/workspace/final/Target.PEBP.subfamily_evidence.tree.nwk"
+
+ class Decision:
+ resolved = True
+ selected = Selected()
+ candidates = []
+
+ def to_dict(self):
+ return {
+ "resolved": True,
+ "selected": {
+ "path": self.selected.path,
+ "kind": "newick",
+ "source": "artifact:phylogeny:tree",
+ },
+ "candidates": [],
+ }
+
+ class Resolver:
+ def __init__(self, _context):
+ pass
+
+ def resolve_stage(self, stage, species="", family=""):
+ return {
+ "family_members_pep": Decision(),
+ "tree": Decision(),
+ }
+
+ context = SimpleNamespace(
+ family="PEBP",
+ target_species=["Os"],
+ reference_species=["At"],
+ comparative_species=[],
+ config_path="/workspace/input/config.xlsx",
+ )
+ monkeypatch.setattr(main, "build_project_context", lambda **_kwargs: context)
+ monkeypatch.setattr(main, "ArtifactResolver", Resolver)
+ monkeypatch.setattr(
+ main,
+ "_validate_gene_family_stage_context",
+ lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
+ )
+
+ result = main._api_semantic_preflight(
+ "job-direct-motif",
+ "motif",
+ "Os.PEBP",
+ {
+ "geneFamily": "PEBP",
+ "targetSpecies": ["Os"],
+ "referenceSpecies": ["At"],
+ "scopeMode": "target_plus_reference",
+ "analysisRoute": "subfamily_evidence",
+ },
+ )
+
+ assert result["valid"] is True
+ assert result["materialized_params"]["treeFile"] == Selected.path
+
+
+def test_scoped_tree_resolution_prefers_preflight_selected_tree(tmp_path):
+ from apps.web import pipeline_backend
+
+ workspace = tmp_path / "workspace"
+ final_dir = workspace / "final"
+ final_dir.mkdir(parents=True)
+ scope_file = final_dir / "Os.PEBP.scope.json"
+ scope_file.write_text(
+ json.dumps({
+ "scope_id": "PEBP.target-Os.ref-At.target_plus_reference",
+ "scope_mode": "target_plus_reference",
+ "target_species": ["Os"],
+ "reference_species": ["At"],
+ }),
+ encoding="utf-8",
+ )
+ selected_tree = final_dir / "Target.PEBP.subfamily_evidence.tree.nwk"
+ selected_tree.write_text("(Os1,At1);\n", encoding="utf-8")
+
+ resolved, _scope = pipeline_backend.resolve_scoped_tree_from_phylogeny(
+ workspace,
+ final_dir,
+ "Os.PEBP",
+ scope_file,
+ explicit_tree_file=str(selected_tree),
+ )
+
+ assert resolved == selected_tree.resolve()
+
+
+def test_explicit_subfamily_and_regular_routes_remain_separate():
+ shared = {
+ "geneFamily": "SPL",
+ "targetSpecies": ["Ft", "Os"],
+ "referenceSpecies": ["At"],
+ "scopeMode": "target_plus_reference",
+ "analysisMode": "joint_multi_species",
+ }
+
+ evidence = enrich_stage_params_with_context(
+ "motif",
+ "Target.SPL",
+ {**shared, "analysisRoute": "subfamily_evidence"},
+ )
+ prediction = enrich_stage_params_with_context(
+ "reference_classification",
+ "Target.SPL",
+ {**shared, "analysisRoute": "subfamily_prediction"},
+ )
+ regular = enrich_stage_params_with_context("motif", "Target.SPL", shared)
+
+ assert evidence["analysisRoute"] == "subfamily_evidence"
+ assert evidence["memberSetId"].endswith(":subfamily_evidence:domain_refined")
+ assert evidence["analysisMode"] == "joint_multi_species"
+ assert prediction["analysisRoute"] == "subfamily_prediction"
+ assert regular["analysisRoute"] == "multi_species"
+ assert regular["memberSetId"].endswith(":multi_species:all_members")
+
+
+def test_direct_motif_request_uses_job_subfamily_state(monkeypatch):
+ main = _load_vue3_main()
+ monkeypatch.setattr(
+ main,
+ "_load_identify_species_selection",
+ lambda _job_id: {
+ "groups": {
+ "target": [{"species": "Ft"}],
+ "reference": [{"species": "At"}],
+ "comparative": [],
+ }
+ },
+ )
+ monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
+ monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
+ monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
+ monkeypatch.setattr(
+ main,
+ "_subfamily_state_for_targets",
+ lambda _job_id, _species, _family: {
+ "all_confirmed": False,
+ "by_species": {"Ft": {"status": "not_classified"}},
+ "issues": [{"code": "subfamily_classification_missing"}],
+ },
+ )
+ monkeypatch.setattr(
+ main,
+ "_validate_gene_family_stage_context",
+ lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
+ )
+
+ contextual = main._resolve_contextual_gene_family_request(
+ "job-direct-motif",
+ "执行 Motif 分析",
+ "Ft.SPL",
+ requested_stages=["motif"],
+ )
+
+ assert contextual["stages"] == ["motif"]
+ assert contextual["targetSpecies"] == ["Ft"]
+ assert contextual["referenceSpecies"] == ["At"]
+ assert contextual["contextGate"]["needs_subfamily_decision"] is True
+
+
+def test_direct_stage_preserves_species_roles_from_intent_when_selector_is_empty(monkeypatch):
+ main = _load_vue3_main()
+ monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
+ monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "PEBP")
+ monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
+ monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
+ monkeypatch.setattr(
+ main,
+ "_subfamily_state_for_targets",
+ lambda _job_id, _species, _family: {
+ "all_confirmed": False,
+ "by_species": {"Os": {"status": "not_classified"}},
+ "issues": [],
+ },
+ )
+ monkeypatch.setattr(
+ main,
+ "_validate_gene_family_stage_context",
+ lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
+ )
+
+ contextual = main._resolve_contextual_gene_family_request(
+ "job-direct-motif",
+ "执行 Motif 分析",
+ "Os.PEBP",
+ requested_stages=["motif"],
+ request_intent={
+ "target_species": ["Os"],
+ "reference_species": ["At"],
+ "params": {
+ "targetSpecies": ["Os"],
+ "referenceSpecies": ["At"],
+ },
+ },
+ )
+
+ assert contextual["targetSpecies"] == ["Os"]
+ assert contextual["referenceSpecies"] == ["At"]
+ assert contextual["scopeMode"] == "target_plus_reference"
+
+ request = SimpleNamespace(
+ job_id="job-direct-motif",
+ message="执行 Motif 分析",
+ prefix="Os.PEBP",
+ intent={
+ "stages": ["motif"],
+ "params": {
+ "targetSpecies": ["Os"],
+ "referenceSpecies": ["At"],
+ },
+ },
+ )
+ response = main._build_contextual_gene_family_plan_response(
+ request,
+ "session-direct-motif",
+ contextual,
+ )
+ details = {item["id"]: item for item in response["plan"]["all_stages_detail"]}
+
+ assert details["phylogeny"]["params"]["analysisRoute"] == "subfamily_evidence"
+ assert details["motif"]["params"]["analysisRoute"] == "subfamily_evidence"
+ assert details["phylogeny"]["params"]["memberSetId"] == details["motif"]["params"]["memberSetId"]