Files
Arcrun/cli/src/commands/validate.ts
T
Leo 7bd4ab0a6e fix(cli): address Gemini test report — local mode, validate bug, offline flag
A. acr init --local: new local mode, no Cloudflare account required;
   config defaults to mode:local when ~/.arcrun/config.yaml missing

B. validate node-count bug: removed faulty input/output node heuristic
   that dropped start/end nodes from config check; now all nodes except
   reserved 'input' keyword must have config entries

C. acr validate --offline: skip remote component-existence and credentials
   checks; local mode also auto-skips these checks

D. parts.ts: replace require('node:fs') with static import (ES module fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:53:30 +08:00

150 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* acr validate <workflow.yaml>
* 在執行前驗證 YAML 格式、關係詞合法性、零件是否存在、credentials 是否已上傳
*/
import chalk from 'chalk';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { loadWorkflowYaml, parseTriplets, validateRelations, getNodeNames } from '../lib/yaml-parser.js';
import { CfKvClient } from '../lib/cf-api.js';
export async function cmdValidate(filePath: string, options: { offline?: boolean } = {}): Promise<void> {
const config = loadConfig();
let allPassed = true;
const check = (label: string, ok: boolean, detail?: string) => {
const icon = ok ? chalk.green('✓') : chalk.red('✗');
console.log(` ${icon} ${label}${detail ? ` ${chalk.gray(detail)}` : ''}`);
if (!ok) allPassed = false;
};
console.log(chalk.bold(`\n 驗證 ${filePath}\n`));
// 1. YAML 格式
let workflow;
try {
workflow = loadWorkflowYaml(filePath);
check('YAML 格式正確', true, `name=${workflow.name}`);
} catch (e) {
check('YAML 格式', false, e instanceof Error ? e.message : String(e));
process.exit(1);
}
// 2. 三元組解析
let triplets;
try {
triplets = parseTriplets(workflow.flow);
check('三元組解析', true, `${triplets.length} 條`);
} catch (e) {
check('三元組解析', false, e instanceof Error ? e.message : String(e));
process.exit(1);
}
// 3. 關係詞驗證(不允許 PIPE)
try {
validateRelations(triplets);
check('關係詞合法性', true);
} catch (e) {
check('關係詞合法性', false, e instanceof Error ? e.message : String(e));
allPassed = false;
}
// 4. 所有節點在 config 中有對應
// 規則:除了名稱為 "input" 的保留字節點,其餘所有節點都必須有 config
// 注意:不能用「只出現在 subject 或只出現在 object」來判斷是否需要 config
// 因為像 a >> b >> c 中 a、b、c 都可能需要 config
const nodeNames = getNodeNames(triplets);
const RESERVED_NODES = new Set(['input']); // 保留字節點不需要 config
const componentNodes = nodeNames.filter(n => !RESERVED_NODES.has(n));
const missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]);
if (missingConfigs.length > 0) {
check('config 完整性', false, `缺少 config${missingConfigs.join(', ')}`);
allPassed = false;
} else {
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
}
// 5. 零件存在性(--offline 時跳過)
if (options.offline || config.mode === 'local') {
check('零件存在性', true, '離線模式,跳過遠端檢查');
} else {
const executorUrl = getCypherExecutorUrl(config);
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
const res = await fetch(`${executorUrl}/cypher/search`, {
method: 'POST',
headers,
body: JSON.stringify({ triplets: workflow.flow }),
});
if (res.ok) {
const data = await res.json() as { missing: string[] };
if (data.missing.length > 0) {
check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`);
allPassed = false;
} else {
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
}
} else {
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
} catch {
check('零件存在性', false, `無法連線 ${executorUrl}(加 --offline 跳過此檢查)`);
}
}
// 6. Credentials 上傳檢查(--offline 或 local 模式時跳過)
if (options.offline || config.mode === 'local') {
// 離線模式略過
} else if (config.cloudflare_account_id && config.cf_api_token) {
const namespaceId = config.mode === 'standard'
? config.user_kv_namespace_id
: config.credentials_kv_namespace_id;
if (namespaceId) {
const kv = new CfKvClient({
accountId: config.cloudflare_account_id,
namespaceId,
apiToken: config.cf_api_token,
});
try {
const kvKeys = await kv.list('cred:');
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
const usedCreds = extractCredentialRefs(workflow.config ?? {});
const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k));
if (missingCreds.length > 0) {
check('Credentials 上傳', false, `未上傳:${missingCreds.join(', ')}(執行 acr creds push`);
allPassed = false;
} else {
check('Credentials 上傳', true, `已上傳 ${uploadedCreds.size} 個 credential`);
}
} catch {
check('Credentials 上傳', false, 'KV 查詢失敗,跳過驗證');
}
}
}
console.log('');
if (allPassed) {
console.log(chalk.green.bold(' ✓ 驗證通過\n'));
} else {
console.log(chalk.red.bold(' ✗ 驗證未通過,請修正上方錯誤\n'));
process.exit(1);
}
}
/** 從 workflow config 中提取可能的 credential key 引用(模板 {{creds.xxx}}*/
function extractCredentialRefs(config: Record<string, Record<string, unknown>>): string[] {
const refs = new Set<string>();
const jsonStr = JSON.stringify(config);
const matches = jsonStr.matchAll(/\{\{creds\.([^}]+)\}\}/g);
for (const m of matches) {
refs.add(m[1]);
}
return [...refs];
}