diff --git a/frontend/src/components/DefinitionViewer.object-edit.test.tsx b/frontend/src/components/DefinitionViewer.object-edit.test.tsx index a215792..9735390 100644 --- a/frontend/src/components/DefinitionViewer.object-edit.test.tsx +++ b/frontend/src/components/DefinitionViewer.object-edit.test.tsx @@ -114,6 +114,7 @@ describe('DefinitionViewer object edit entry', () => { type: 'query', connectionId: 'conn-1', dbName: 'main', + queryMode: 'object-edit', query: expect.stringContaining('CREATE OR REPLACE VIEW reporting.active_users AS'), })); expect(storeState.addTab.mock.calls[0][0].query).toContain('SELECT id, name FROM users;'); @@ -172,6 +173,7 @@ describe('DefinitionViewer object edit entry', () => { expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ title: '修改函数/存储过程: reporting.refresh_stats', type: 'query', + queryMode: 'object-edit', query: expect.stringContaining('CREATE OR REPLACE FUNCTION reporting.refresh_stats()'), })); }); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index 9f72f2e..823df86 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -612,6 +612,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { connectionId: tab.connectionId, dbName, query, + queryMode: 'object-edit', }); } finally { if (isMountedRef.current) { diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index f9c532d..5166d13 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1331,6 +1331,30 @@ describe('QueryEditor external SQL save', () => { })); }); + it('skips heavy autocomplete metadata fetch for object edit query tabs', async () => { + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] }); + + await act(async () => { + create(); + }); + await act(async () => { + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + }); + + expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1); + expect(backendApp.DBGetTables).not.toHaveBeenCalled(); + expect(backendApp.DBGetAllColumns).not.toHaveBeenCalled(); + expect(backendApp.DBQuery).not.toHaveBeenCalled(); + expect(editorState.editor.deltaDecorations).toHaveBeenCalledWith([], []); + }); + it('keeps the editor empty when a tab draft is externally synced to an empty query', async () => { let renderer!: ReactTestRenderer; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index d1c663c..c5190ea 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1912,6 +1912,7 @@ const resolveQueryLocatorPlan = async ({ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { const [query, setQuery] = useState(getInitialEditorQuery(tab)); const isExternalSQLFileTab = Boolean(String(tab.filePath || '').trim()); + const isObjectEditQueryTab = tab.type === 'query' && tab.queryMode === 'object-edit'; type ResultSet = { key: string; @@ -2127,6 +2128,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } + if (isObjectEditQueryTab) { + objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []); + return; + } + const text = getQueryEditorDecorationModelTextIfLightweight(model, maxTextLength); if (text === null) { objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []); @@ -2173,7 +2179,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations); - }, []); + }, [isObjectEditQueryTab]); const showObjectInfoAtPosition = useCallback((position?: { lineNumber: number; column: number } | null) => { const editor = editorRef.current; @@ -2380,7 +2386,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { - if (!autoFetchVisible) { + if (!autoFetchVisible || isObjectEditQueryTab) { return; } @@ -2584,7 +2590,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc refreshObjectDecorations(); }; void fetchMetadata(); - }, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, refreshObjectDecorations]); // dbList 变化时触发重新加载 + }, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, isObjectEditQueryTab, refreshObjectDecorations]); // dbList 变化时触发重新加载 // Query ID management helpers const setQueryId = (id: string) => { diff --git a/frontend/src/components/TriggerViewer.object-edit.test.tsx b/frontend/src/components/TriggerViewer.object-edit.test.tsx index 28eb4b9..a2312a5 100644 --- a/frontend/src/components/TriggerViewer.object-edit.test.tsx +++ b/frontend/src/components/TriggerViewer.object-edit.test.tsx @@ -112,6 +112,7 @@ describe('TriggerViewer object edit entry', () => { type: 'query', connectionId: 'conn-1', dbName: 'main', + queryMode: 'object-edit', query: expect.stringContaining('CREATE TRIGGER users_bi BEFORE INSERT'), })); }); @@ -172,6 +173,99 @@ describe('TriggerViewer object edit entry', () => { expect(query).not.toContain('请补全 CREATE TRIGGER 语句'); }); + it('rebuilds mysql trigger ddl from metadata rows when only action statement is available', async () => { + storeState.connections[0].config.type = 'mysql'; + backendApp.DBQuery.mockResolvedValue({ + success: true, + data: [{ + TRIGGER_NAME: 'users_bi', + TRIGGER_SCHEMA: 'main', + EVENT_OBJECT_SCHEMA: 'audit', + EVENT_OBJECT_TABLE: 'users', + ACTION_TIMING: 'BEFORE', + EVENT_MANIPULATION: 'INSERT', + ACTION_ORIENTATION: 'ROW', + ACTION_STATEMENT: 'SET NEW.created_at = NOW()', + }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('CREATE TRIGGER `main`.`users_bi`'); + expect(query).toContain('BEFORE INSERT ON `audit`.`users`'); + expect(query).toContain('FOR EACH ROW'); + expect(query).toContain('SET NEW.created_at = NOW();'); + expect(query).not.toContain('请补全 CREATE TRIGGER 语句'); + }); + + it('rebuilds oracle trigger ddl from metadata rows when body query returns fragments only', async () => { + storeState.connections[0].config.type = 'oracle'; + backendApp.DBQuery + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ + success: true, + data: [{ + OWNER: 'AUDIT', + TABLE_OWNER: 'AUDIT', + TABLE_NAME: 'USERS', + TRIGGER_NAME: 'USERS_BU', + TRIGGER_TYPE: 'BEFORE EACH ROW', + TRIGGERING_EVENT: 'UPDATE', + WHEN_CLAUSE: 'NEW.UPDATED_AT IS NULL', + TRIGGER_BODY: 'BEGIN\n :NEW.UPDATED_AT := SYSDATE;\nEND;', + }], + }) + .mockResolvedValueOnce({ success: true, data: [] }) + .mockResolvedValueOnce({ + success: true, + data: [{ + OWNER: 'AUDIT', + TABLE_OWNER: 'AUDIT', + TABLE_NAME: 'USERS', + TRIGGER_NAME: 'USERS_BU', + TRIGGER_TYPE: 'BEFORE EACH ROW', + TRIGGERING_EVENT: 'UPDATE', + WHEN_CLAUSE: 'NEW.UPDATED_AT IS NULL', + TRIGGER_BODY: 'BEGIN\n :NEW.UPDATED_AT := SYSDATE;\nEND;', + }], + }); + + let renderer: any; + await act(async () => { + renderer = create(); + await flushPromises(); + }); + + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + + await act(async () => { + button.props.onClick(); + }); + + const query = storeState.addTab.mock.calls[0][0].query; + expect(query).toContain('CREATE OR REPLACE TRIGGER AUDIT.USERS_BU'); + expect(query).toContain('BEFORE UPDATE ON AUDIT.USERS'); + expect(query).toContain('FOR EACH ROW'); + expect(query).toContain('WHEN (NEW.UPDATED_AT IS NULL)'); + expect(query).toContain(':NEW.UPDATED_AT := SYSDATE;'); + }); + it('reloads the latest trigger definition before opening object edit', async () => { backendApp.DBQuery .mockResolvedValueOnce({ diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index ff5f984..17d6ab0 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -19,6 +19,92 @@ const ensureSqlStatementTerminator = (sql: string): string => { return /;\s*$/.test(normalized) ? normalized : `${normalized};`; }; +const getCaseInsensitiveRawValue = (row: Record, keys: string[]): any => { + const normalizedKeyMap = new Map(); + Object.keys(row || {}).forEach((key) => normalizedKeyMap.set(key.toLowerCase(), key)); + for (const key of keys) { + const matchedKey = normalizedKeyMap.get(String(key || '').toLowerCase()); + if (!matchedKey) continue; + const value = row[matchedKey]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return value; + } + } + return undefined; +}; + +const buildMySQLTriggerDDLFromMetadata = ( + row: Record, + fallbackTriggerName: string, + fallbackTableName: string, +): string => { + const triggerName = String( + getCaseInsensitiveRawValue(row, ['trigger_name', 'Trigger', 'TRIGGER_NAME']) + || splitQualifiedNameLast(fallbackTriggerName).objectName + || fallbackTriggerName, + ).trim(); + const triggerSchema = String(getCaseInsensitiveRawValue(row, ['trigger_schema', 'TRIGGER_SCHEMA']) || '').trim(); + const eventSchema = String(getCaseInsensitiveRawValue(row, ['event_object_schema', 'EVENT_OBJECT_SCHEMA']) || '').trim(); + const eventTable = String( + getCaseInsensitiveRawValue(row, ['event_object_table', 'EVENT_OBJECT_TABLE', 'table_name', 'TABLE_NAME']) + || splitQualifiedNameLast(fallbackTableName).objectName + || fallbackTableName, + ).trim(); + const actionTiming = String(getCaseInsensitiveRawValue(row, ['action_timing', 'ACTION_TIMING']) || '').trim().toUpperCase(); + const eventManipulation = String(getCaseInsensitiveRawValue(row, ['event_manipulation', 'EVENT_MANIPULATION']) || '').trim().toUpperCase(); + const actionOrientation = String(getCaseInsensitiveRawValue(row, ['action_orientation', 'ACTION_ORIENTATION']) || '').trim().toUpperCase(); + const actionStatement = String(getCaseInsensitiveRawValue(row, ['action_statement', 'ACTION_STATEMENT']) || '').trim(); + + if (!triggerName || !eventTable || !actionTiming || !eventManipulation || !actionStatement) { + return ''; + } + + const qualifiedTriggerName = triggerSchema ? `\`${triggerSchema.replace(/`/g, '``')}\`.\`${triggerName.replace(/`/g, '``')}\`` : `\`${triggerName.replace(/`/g, '``')}\``; + const qualifiedTableName = eventSchema ? `\`${eventSchema.replace(/`/g, '``')}\`.\`${eventTable.replace(/`/g, '``')}\`` : `\`${eventTable.replace(/`/g, '``')}\``; + const orientationClause = actionOrientation === 'ROW' ? '\nFOR EACH ROW' : ''; + return `CREATE TRIGGER ${qualifiedTriggerName}\n${actionTiming} ${eventManipulation} ON ${qualifiedTableName}${orientationClause}\n${actionStatement}`; +}; + +const buildOracleLikeTriggerDDLFromMetadata = ( + row: Record, + fallbackTriggerName: string, + fallbackTableName: string, +): string => { + const triggerName = String( + getCaseInsensitiveRawValue(row, ['trigger_name', 'TRIGGER_NAME']) + || splitQualifiedNameLast(fallbackTriggerName).objectName + || fallbackTriggerName, + ).trim(); + const owner = String(getCaseInsensitiveRawValue(row, ['owner', 'OWNER']) || splitQualifiedNameLast(fallbackTriggerName).parentPath || '').trim(); + const tableOwner = String(getCaseInsensitiveRawValue(row, ['table_owner', 'TABLE_OWNER']) || splitQualifiedNameLast(fallbackTableName).parentPath || '').trim(); + const tableName = String( + getCaseInsensitiveRawValue(row, ['table_name', 'TABLE_NAME']) + || splitQualifiedNameLast(fallbackTableName).objectName + || fallbackTableName, + ).trim(); + const triggerType = String(getCaseInsensitiveRawValue(row, ['trigger_type', 'TRIGGER_TYPE']) || '').trim(); + const triggeringEvent = String(getCaseInsensitiveRawValue(row, ['triggering_event', 'TRIGGERING_EVENT']) || '').trim(); + const whenClause = String(getCaseInsensitiveRawValue(row, ['when_clause', 'WHEN_CLAUSE']) || '').trim(); + const triggerBody = String(getCaseInsensitiveRawValue(row, ['trigger_body', 'TRIGGER_BODY']) || '').trim(); + + if (!triggerName || !tableName || !triggerType || !triggeringEvent || !triggerBody) { + return ''; + } + + const qualifiedTriggerName = owner ? `${owner}.${triggerName}` : triggerName; + const qualifiedTableName = tableOwner ? `${tableOwner}.${tableName}` : tableName; + const normalizedWhenClause = whenClause ? `\nWHEN (${whenClause.replace(/^\((.*)\)$/s, '$1')})` : ''; + const normalizedTriggerType = triggerType.replace(/\s+/g, ' ').trim(); + const triggerTypeMatch = normalizedTriggerType.match(/^(BEFORE|AFTER|INSTEAD OF)(?:\s+(EACH ROW|STATEMENT))?$/i); + if (triggerTypeMatch) { + const timing = String(triggerTypeMatch[1] || '').toUpperCase(); + const firingLevel = String(triggerTypeMatch[2] || '').toUpperCase(); + const forEachRowClause = firingLevel === 'EACH ROW' ? '\nFOR EACH ROW' : ''; + return `CREATE OR REPLACE TRIGGER ${qualifiedTriggerName}\n${timing} ${triggeringEvent} ON ${qualifiedTableName}${forEachRowClause}${normalizedWhenClause}\n${triggerBody}`; + } + return `CREATE OR REPLACE TRIGGER ${qualifiedTriggerName}\n${triggerType} ${triggeringEvent} ON ${qualifiedTableName}${normalizedWhenClause}\n${triggerBody}`; +}; + const buildEditableTriggerSql = (triggerName: string, triggerDefinition: string): string => { const normalizedName = String(triggerName || '').trim(); const normalizedDefinition = String(triggerDefinition || '').trim(); @@ -102,7 +188,7 @@ const TriggerViewer: React.FC = ({ tab }) => { return [ `SHOW CREATE TRIGGER \`${name.replace(/`/g, '``')}\``, safeDbName - ? `SELECT ACTION_STATEMENT AS trigger_definition FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeTriggerName}' LIMIT 1` + ? `SELECT TRIGGER_NAME, TRIGGER_SCHEMA, EVENT_OBJECT_SCHEMA, EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORIENTATION, ACTION_STATEMENT FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeTriggerName}' LIMIT 1` : '', safeDbName ? `SHOW TRIGGERS FROM \`${dbName.replace(/`/g, '``')}\` LIKE '${safeTriggerName}'` @@ -125,12 +211,21 @@ LIMIT 1`]; case 'oracle': case 'dm': if (schema) { - return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`]; + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeTriggerName.toUpperCase()}', '${escapeSQLLiteral(schema).toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT OWNER, TABLE_OWNER, TABLE_NAME, TRIGGER_NAME, TRIGGER_TYPE, TRIGGERING_EVENT, WHEN_CLAUSE, TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`, + ]; } if (!safeDbName) { - return [`SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`]; + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeTriggerName.toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT TABLE_NAME, TRIGGER_NAME, TRIGGER_TYPE, TRIGGERING_EVENT, WHEN_CLAUSE, TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`, + ]; } - return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`]; + return [ + `SELECT DBMS_METADATA.GET_DDL('TRIGGER', '${safeTriggerName.toUpperCase()}', '${safeDbName.toUpperCase()}') AS trigger_definition FROM DUAL`, + `SELECT OWNER, TABLE_OWNER, TABLE_NAME, TRIGGER_NAME, TRIGGER_TYPE, TRIGGERING_EVENT, WHEN_CLAUSE, TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`, + ]; case 'sqlite': return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`]; case 'duckdb': @@ -202,21 +297,25 @@ LIMIT 1`]; return ''; }; - const extractTriggerDefinition = (dialect: string, data: any[]): string => { + const extractTriggerDefinition = (dialect: string, data: any[], fallbackTriggerName: string, fallbackTableName: string): string => { if (!data || data.length === 0) { return '-- 未找到触发器定义'; } - const row = data[0]; + const row = data[0] as Record; switch (dialect) { case 'mysql': case 'starrocks': { // MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ... const keys = Object.keys(row); + const metadataDDL = buildMySQLTriggerDDLFromMetadata(row, fallbackTriggerName, fallbackTableName); if (row.trigger_definition || row.TRIGGER_DEFINITION) { return String(row.trigger_definition || row.TRIGGER_DEFINITION); } + if (metadataDDL) { + return metadataDDL; + } if (row.ACTION_STATEMENT || row.action_statement) { return String(row.ACTION_STATEMENT || row.action_statement); } @@ -243,6 +342,14 @@ LIMIT 1`]; } case 'oracle': case 'dm': { + const ddl = String(row.trigger_definition || row.TRIGGER_DEFINITION || '').trim(); + if (ddl) { + return ddl; + } + const metadataDDL = buildOracleLikeTriggerDDLFromMetadata(row, fallbackTriggerName, fallbackTableName); + if (metadataDDL) { + return metadataDDL; + } return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || ''; } case 'sqlite': { @@ -287,7 +394,10 @@ LIMIT 1`]; const result = await runQueryCandidates(config, dbName, queries); if (result.success && Array.isArray(result.data) && result.data.length > 0) { - return { success: true, definition: extractTriggerDefinition(dialect, result.data) }; + return { + success: true, + definition: extractTriggerDefinition(dialect, result.data, triggerName, String(tab.triggerTableName || '')), + }; } if (result.success) { @@ -391,6 +501,7 @@ LIMIT 1`]; connectionId: tab.connectionId, dbName, query: buildEditableTriggerSql(triggerName, latestDefinition), + queryMode: 'object-edit', }); } finally { if (isMountedRef.current) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0e63765..2dcebfe 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -412,6 +412,7 @@ export interface TabData { dbName?: string; tableName?: string; query?: string; + queryMode?: "standard" | "object-edit"; filePath?: string; initialTab?: string; readOnly?: boolean; diff --git a/tools/verify-driver-agent-revisions.sh b/tools/verify-driver-agent-revisions.sh index 74a7ade..7069b2c 100755 --- a/tools/verify-driver-agent-revisions.sh +++ b/tools/verify-driver-agent-revisions.sh @@ -99,6 +99,15 @@ expected_revision_for() { ' internal/db/driver_agent_revisions_gen.go } +build_tags_for_driver() { + local driver="$1" + local tags="gonavi_${driver}_driver" + if [[ "$driver" == "duckdb" && "$host_goos" == "windows" && "$host_goarch" == "amd64" ]]; then + tags="${tags} duckdb_use_lib" + fi + printf '%s\n' "$tags" +} + agent_path_for() { local driver="$1" local public_name asset @@ -125,9 +134,97 @@ print(data.get("agentRevision", "")) ' } +probe_host_agent_revision() { + local driver="$1" + local build_tags probe_dir probe_path revision + build_tags="$(build_tags_for_driver "$driver")" + probe_dir="$(mktemp -d)" + probe_path="${probe_dir}/probe-agent" + if [[ "$host_goos" == "windows" ]]; then + probe_path="${probe_path}.exe" + fi + + CGO_ENABLED=0 go build \ + -tags "${build_tags}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${probe_path}" \ + ./cmd/optional-driver-agent >/dev/null + + chmod +x "$probe_path" 2>/dev/null || true + revision="$(probe_agent_revision "$probe_path")" + rm -rf "$probe_dir" + printf '%s\n' "$revision" +} + +can_execute_target_binary() { + [[ "$target_platform" == "$host_platform" ]] +} + +validate_windows_pe_machine() { + local agent_path="$1" + local expected_goarch="$2" + python3 - "$agent_path" "$expected_goarch" <<'PY' +import os +import struct +import sys + +path = sys.argv[1] +goarch = sys.argv[2].strip().lower() +expected = { + "386": 0x014C, + "amd64": 0x8664, + "arm64": 0xAA64, +} +labels = { + 0x014C: "windows-386", + 0x8664: "windows-amd64", + 0xAA64: "windows-arm64", +} + +if goarch not in expected: + sys.exit(0) + +with open(path, "rb") as fh: + fh.seek(0, os.SEEK_END) + size = fh.tell() + if size < 0x40: + raise SystemExit("文件头不完整") + + fh.seek(0) + if fh.read(2) != b"MZ": + raise SystemExit("缺少 MZ 头") + + fh.seek(0x3C) + pe_offset_raw = fh.read(4) + if len(pe_offset_raw) != 4: + raise SystemExit("读取 PE 头偏移失败") + pe_offset = struct.unpack(" size: + raise SystemExit("PE 头不完整") + + fh.seek(pe_offset) + if fh.read(4) != b"PE\0\0": + raise SystemExit("缺少 PE 签名") + + machine_raw = fh.read(2) + if len(machine_raw) != 2: + raise SystemExit("读取 PE 架构失败") + machine = struct.unpack("/dev/null || true - actual="$(probe_agent_revision "$agent_path" || true)" + if [[ "$goos" == "windows" ]]; then + if ! validate_windows_pe_machine "$agent_path" "$goarch"; then + echo "❌ $driver Windows driver-agent 架构校验失败:asset=$agent_path target=$target_platform" + failed=1 + continue + fi + fi + + actual="" + if can_execute_target_binary; then + actual="$(probe_agent_revision "$agent_path" || true)" + else + echo "ℹ️ runner 平台 ${host_platform} 无法直接执行目标二进制 ${target_platform},已先完成目标资产架构校验,再用 host-native probe 校验相同 build tags 的 revision" + actual="$(probe_host_agent_revision "$driver" || true)" + fi + if [[ "$actual" != "$expected" ]]; then echo "❌ $driver driver-agent revision 不匹配:asset=$agent_path actual=${actual:-空} expected=$expected" failed=1