# Task: 给 meta workflow 加 gate role (pulse#11)
This commit is contained in:
parent
41b831a2a0
commit
ab7ca6328c
129
src/workflows/meta.ts
Normal file
129
src/workflows/meta.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Meta Workflow — workflow for developing workflows.
|
||||
*
|
||||
* Roles:
|
||||
* gate (code) → preflight checks (no LLM)
|
||||
* coder (Agent) → implement code
|
||||
* checker (code) → validate file scope + build + unit test
|
||||
* tester (code) → e2e lifecycle verification + commit/push on pass
|
||||
*
|
||||
* Flow:
|
||||
* START → gate
|
||||
* → fail → END (unsuitable task)
|
||||
* → pass → coder → checker
|
||||
* → fail → coder (file scope violation or build/test fail)
|
||||
* checker pass → tester
|
||||
* → pass → END (with commit + push)
|
||||
* → fail → coder (e2e fail, with diagnostic)
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
END,
|
||||
type ModeratorInput,
|
||||
type Role,
|
||||
START,
|
||||
type WorkflowType,
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
import { createMetaGateRole, type GateMeta } from './roles/meta-gate.js';
|
||||
|
||||
// ── Meta Types ─────────────────────────────────────────────────
|
||||
|
||||
export interface MetaCoderMeta {
|
||||
[key: string]: unknown;
|
||||
filesChanged: string[];
|
||||
testsPassed: boolean;
|
||||
}
|
||||
|
||||
export interface MetaCheckerMeta {
|
||||
[key: string]: unknown;
|
||||
pass: boolean;
|
||||
reason: string;
|
||||
violations?: string[];
|
||||
}
|
||||
|
||||
export interface MetaTesterMeta {
|
||||
[key: string]: unknown;
|
||||
pass: boolean;
|
||||
reason: string;
|
||||
/** Only present when pass=true */
|
||||
commitHash?: string;
|
||||
pushed?: boolean;
|
||||
}
|
||||
|
||||
export type MetaWorkflowRoles = {
|
||||
gate: Role<GateMeta>;
|
||||
coder: Role<MetaCoderMeta>;
|
||||
checker: Role<MetaCheckerMeta>;
|
||||
tester: Role<MetaTesterMeta>;
|
||||
};
|
||||
|
||||
// ── Moderator ──────────────────────────────────────────────────
|
||||
|
||||
function metaModerator(
|
||||
input: ModeratorInput<MetaWorkflowRoles>,
|
||||
_topicId: string,
|
||||
): (keyof MetaWorkflowRoles & string) | typeof END {
|
||||
switch (input.role) {
|
||||
case START:
|
||||
return 'gate';
|
||||
case 'gate': {
|
||||
const meta = input.meta as GateMeta | null;
|
||||
return meta?.pass ? 'coder' : END;
|
||||
}
|
||||
case 'coder':
|
||||
return 'checker';
|
||||
case 'checker': {
|
||||
const meta = input.meta as MetaCheckerMeta | null;
|
||||
return meta?.pass ? 'tester' : 'coder';
|
||||
}
|
||||
case 'tester': {
|
||||
const meta = input.meta as MetaTesterMeta | null;
|
||||
return meta?.pass ? END : 'coder';
|
||||
}
|
||||
default:
|
||||
return END;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub roles for `createMetaWorkflow()` with no args (e.g. meta-tester dynamic import). */
|
||||
function createDefaultMetaRoles(engineDir: string): MetaWorkflowRoles {
|
||||
const stubCoder: Role<MetaCoderMeta> = async () => ({
|
||||
content: 'e2e stub coder',
|
||||
meta: { filesChanged: [], testsPassed: true },
|
||||
});
|
||||
const stubChecker: Role<MetaCheckerMeta> = async () => ({
|
||||
content: 'ok',
|
||||
meta: { pass: true, reason: 'e2e stub' },
|
||||
});
|
||||
const stubTester: Role<MetaTesterMeta> = async () => ({
|
||||
content: 'e2e stub',
|
||||
meta: { pass: true, reason: 'e2e stub' },
|
||||
});
|
||||
|
||||
return {
|
||||
gate: createMetaGateRole({ engineDir }),
|
||||
coder: stubCoder,
|
||||
checker: stubChecker,
|
||||
tester: stubTester,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param roles — 若省略,使用内置 stub(供 e2e / `createMetaWorkflow()` 无参调用)。
|
||||
*/
|
||||
export function createMetaWorkflow(
|
||||
roles?: MetaWorkflowRoles,
|
||||
): WorkflowType<MetaWorkflowRoles> {
|
||||
const engineDir = fileURLToPath(new URL('../..', import.meta.url));
|
||||
return {
|
||||
name: 'meta',
|
||||
roles: roles ?? createDefaultMetaRoles(engineDir),
|
||||
moderator: metaModerator,
|
||||
};
|
||||
}
|
||||
129
src/workflows/roles/meta-checker.ts
Normal file
129
src/workflows/roles/meta-checker.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Meta Checker role — validates coder output before e2e testing.
|
||||
*
|
||||
* Checks:
|
||||
* 1. All changed files are within the allowed directory (engine dir)
|
||||
* 2. No modifications to pulse core files
|
||||
* 3. Build succeeds
|
||||
* 4. Unit tests pass
|
||||
*
|
||||
* Pure code, no LLM.
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import type { Role, RoleResult, WorkflowMessage } from '@uncaged/pulse';
|
||||
|
||||
export interface MetaCheckerMeta {
|
||||
[key: string]: unknown;
|
||||
pass: boolean;
|
||||
reason: string;
|
||||
violations?: string[];
|
||||
}
|
||||
|
||||
export function createMetaCheckerRole(opts: {
|
||||
/** Engine repo directory (allowed file scope) */
|
||||
engineDir: string;
|
||||
/** Extra allowed path prefixes (optional) */
|
||||
allowedPrefixes?: string[];
|
||||
}): Role<MetaCheckerMeta> {
|
||||
return async (
|
||||
_chain: WorkflowMessage[],
|
||||
): Promise<RoleResult<MetaCheckerMeta>> => {
|
||||
const cwd = opts.engineDir;
|
||||
const exec = (cmd: string) =>
|
||||
execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30_000 }).trim();
|
||||
|
||||
const violations: string[] = [];
|
||||
|
||||
// 1. Check git diff — what files did coder change?
|
||||
let changedFiles: string[] = [];
|
||||
try {
|
||||
// Get all uncommitted changes + last commit changes
|
||||
const diffOutput = exec('git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only');
|
||||
changedFiles = diffOutput.split('\n').filter(Boolean);
|
||||
} catch {
|
||||
// No git history — check working tree
|
||||
try {
|
||||
const statusOutput = exec('git status --porcelain');
|
||||
changedFiles = statusOutput
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3).trim());
|
||||
} catch {
|
||||
// Can't check — skip file validation
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
// Allowed: only src/workflows/ (meta workflow scope)
|
||||
const allowedPrefixes = [
|
||||
'src/workflows/',
|
||||
...(opts.allowedPrefixes ?? []),
|
||||
];
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const allowed = allowedPrefixes.some((prefix) =>
|
||||
file.startsWith(prefix),
|
||||
);
|
||||
if (!allowed) {
|
||||
violations.push(`越界文件: ${file}(只允许修改 ${allowedPrefixes.join(', ')} 下的文件)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Blacklist: never touch these even if under src/
|
||||
const blacklist = [
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
'bun.lockb',
|
||||
'.gitignore',
|
||||
];
|
||||
for (const file of changedFiles) {
|
||||
const basename = file.split('/').pop() ?? file;
|
||||
if (blacklist.includes(basename)) {
|
||||
violations.push(`禁止修改: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
return {
|
||||
content: `约束检查失败\n\n${violations.map((v) => `❌ ${v}`).join('\n')}\n\n修改的文件:\n${changedFiles.map((f) => ` ${f}`).join('\n')}`,
|
||||
meta: { pass: false, reason: '文件范围越界', violations },
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Build check
|
||||
try {
|
||||
exec('bun run build 2>&1');
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: `编译失败\n\n---\n${(err.stdout ?? err.message).slice(0, 2000)}`,
|
||||
meta: { pass: false, reason: '编译失败' },
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Unit test check
|
||||
try {
|
||||
const testOutput = exec('bun test src/workflows/ 2>&1');
|
||||
// Check for failures
|
||||
if (testOutput.includes('fail') && !testOutput.includes('0 fail')) {
|
||||
return {
|
||||
content: `单元测试失败\n\n---\n${testOutput.slice(-2000)}`,
|
||||
meta: { pass: false, reason: '单元测试失败' },
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: `单元测试失败\n\n---\n${(err.stdout ?? err.message).slice(0, 2000)}`,
|
||||
meta: { pass: false, reason: '单元测试失败' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `约束检查通过\n\n修改文件: ${changedFiles.length} 个\n编译: ✅\n单元测试: ✅`,
|
||||
meta: { pass: true, reason: '约束检查通过' },
|
||||
};
|
||||
};
|
||||
}
|
||||
86
src/workflows/roles/meta-coder-cursor.ts
Normal file
86
src/workflows/roles/meta-coder-cursor.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Meta Coder role — uses Cursor Agent to implement workflow code.
|
||||
* Uses createAgentExecutorRole with LLM₂ meta parsing.
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import type { LlmClient } from '@uncaged/pulse';
|
||||
import type { MetaCoderMeta } from '../meta.js';
|
||||
import type { Role } from '@uncaged/pulse';
|
||||
import { type AgentRunner, createAgentExecutorRole } from '@uncaged/pulse';
|
||||
|
||||
const PARSE_META_TOOL = {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'extract_coder_meta',
|
||||
description: 'Extract coder execution metadata',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filesChanged: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Files created or modified',
|
||||
},
|
||||
testsPassed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether all tests passed',
|
||||
},
|
||||
},
|
||||
required: ['filesChanged', 'testsPassed'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createMetaCoderRole(
|
||||
runner: AgentRunner,
|
||||
llm: LlmClient,
|
||||
repoDir: string,
|
||||
): Role<MetaCoderMeta> {
|
||||
return createAgentExecutorRole<MetaCoderMeta>(runner, llm, {
|
||||
prepPrompt: (chain, _topicId) => {
|
||||
const startMsg = chain.find((m) => m.role === '__start__');
|
||||
const taskDescription = startMsg?.content ?? '';
|
||||
|
||||
// 如果有 tester 失败反馈,附加
|
||||
const testerMsg = [...chain].reverse().find((m) => m.role === 'tester');
|
||||
const testerFeedback = testerMsg ? `\n\n## 上次验证失败\n${testerMsg.content}` : '';
|
||||
|
||||
const prompt = `# 任务
|
||||
${taskDescription}
|
||||
${testerFeedback}
|
||||
|
||||
## 参考
|
||||
- 先阅读项目结构了解上下文
|
||||
- 参考已有代码风格
|
||||
|
||||
## 步骤
|
||||
1. 理解任务需求
|
||||
2. 写代码实现
|
||||
3. 运行 \`bun run build\` 确认编译通过
|
||||
4. 运行测试确认通过
|
||||
5. 不要 commit,让 workflow 处理
|
||||
|
||||
## 约束
|
||||
- commit author: 小橘 <xiaoju@shazhou.work>
|
||||
- 只修改 $HOME/.upulse/engine/src/workflows/ 下的代码
|
||||
- 不修改 workflow-rule-adapter.ts 和 workflow-type.ts`;
|
||||
|
||||
return { prompt, cwd: repoDir };
|
||||
},
|
||||
parseMeta: {
|
||||
system: '从 Cursor Agent 的输出中提取 coder 执行结果。',
|
||||
tool: PARSE_META_TOOL,
|
||||
parse: (args: string) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return {
|
||||
filesChanged: parsed.filesChanged ?? [],
|
||||
testsPassed: parsed.testsPassed ?? false,
|
||||
} as MetaCoderMeta;
|
||||
},
|
||||
defaultMeta: (_output: string) =>
|
||||
({ filesChanged: [], testsPassed: false }) as MetaCoderMeta,
|
||||
},
|
||||
});
|
||||
}
|
||||
71
src/workflows/roles/meta-gate.test.ts
Normal file
71
src/workflows/roles/meta-gate.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Meta Gate role tests.
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { WorkflowMessage } from '@uncaged/pulse';
|
||||
import { createMetaGateRole } from './meta-gate.js';
|
||||
|
||||
const thisDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
const engineRoot = join(thisDir, '../../..');
|
||||
|
||||
function startChain(content: string): WorkflowMessage[] {
|
||||
return [
|
||||
{
|
||||
role: '__start__',
|
||||
content,
|
||||
meta: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('meta-gate role', () => {
|
||||
it('passes for a normal engine-scoped task', async () => {
|
||||
const gate = createMetaGateRole({ engineDir: engineRoot });
|
||||
const r = await gate(
|
||||
startChain('Add feature under src/workflows/foo.ts'),
|
||||
't1',
|
||||
{} as any,
|
||||
);
|
||||
expect(r.meta?.pass).toBe(true);
|
||||
expect(r.meta?.checks?.every((c) => c.pass)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when task references core package path', async () => {
|
||||
const gate = createMetaGateRole({ engineDir: engineRoot });
|
||||
const r = await gate(
|
||||
startChain('Edit packages/pulse/src/store.ts'),
|
||||
't1',
|
||||
{} as any,
|
||||
);
|
||||
expect(r.meta?.pass).toBe(false);
|
||||
expect(r.meta?.reason).toContain('packages/pulse/src/');
|
||||
});
|
||||
|
||||
it('fails when engine directory does not exist', async () => {
|
||||
const badDir = join(tmpdir(), `missing-engine-${Date.now()}`);
|
||||
const gate = createMetaGateRole({ engineDir: badDir });
|
||||
const r = await gate(
|
||||
startChain('Only touch src/workflows/x.ts'),
|
||||
't1',
|
||||
{} as any,
|
||||
);
|
||||
expect(r.meta?.pass).toBe(false);
|
||||
expect(r.meta?.checks?.some((c) => c.name === 'engine-dir-exists' && !c.pass)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when task content is empty', async () => {
|
||||
const gate = createMetaGateRole({ engineDir: engineRoot });
|
||||
const r = await gate(startChain(' '), 't1', {} as any);
|
||||
expect(r.meta?.pass).toBe(false);
|
||||
expect(r.meta?.reason).toBe('任务内容为空');
|
||||
});
|
||||
});
|
||||
92
src/workflows/roles/meta-gate.ts
Normal file
92
src/workflows/roles/meta-gate.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Meta Gate role — fast pre-checks before coder (no LLM).
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type {
|
||||
PulseStore,
|
||||
Role,
|
||||
RoleResult,
|
||||
WorkflowMessage,
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
export interface GateMeta {
|
||||
pass: boolean;
|
||||
reason?: string;
|
||||
checks: Array<{ name: string; pass: boolean; detail?: string }>;
|
||||
}
|
||||
|
||||
export function createMetaGateRole(opts: {
|
||||
engineDir: string;
|
||||
}): Role<GateMeta> {
|
||||
return async (
|
||||
chain: WorkflowMessage[],
|
||||
_topicId: string,
|
||||
_store: PulseStore,
|
||||
): Promise<RoleResult<GateMeta>> => {
|
||||
const checks: GateMeta['checks'] = [];
|
||||
|
||||
const startMsg = chain.find((m) => m.role === '__start__');
|
||||
if (!startMsg) {
|
||||
return {
|
||||
content: '',
|
||||
meta: { pass: false, reason: '找不到任务描述', checks: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const taskContent = startMsg.content ?? '';
|
||||
if (!taskContent.trim()) {
|
||||
return {
|
||||
content: '',
|
||||
meta: { pass: false, reason: '任务内容为空', checks: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const hasCorePackagePath = /packages\/pulse\/src\//.test(taskContent);
|
||||
checks.push({
|
||||
name: 'no-core-package-paths',
|
||||
pass: !hasCorePackagePath,
|
||||
detail: hasCorePackagePath
|
||||
? '任务涉及核心包路径 packages/pulse/src/,meta workflow 不能改核心包'
|
||||
: 'OK',
|
||||
});
|
||||
|
||||
const engineExists = existsSync(opts.engineDir);
|
||||
checks.push({
|
||||
name: 'engine-dir-exists',
|
||||
pass: engineExists,
|
||||
detail: engineExists ? 'OK' : `Engine 目录不存在: ${opts.engineDir}`,
|
||||
});
|
||||
|
||||
const wfDir = join(opts.engineDir, 'src/workflows');
|
||||
const wfDirExists = existsSync(wfDir);
|
||||
checks.push({
|
||||
name: 'workflows-dir-exists',
|
||||
pass: wfDirExists,
|
||||
detail: wfDirExists ? 'OK' : `Workflows 目录不存在: ${wfDir}`,
|
||||
});
|
||||
|
||||
const allPass = checks.every((c) => c.pass);
|
||||
return {
|
||||
content: allPass
|
||||
? 'Gate passed'
|
||||
: `Gate failed: ${checks
|
||||
.filter((c) => !c.pass)
|
||||
.map((c) => c.detail)
|
||||
.join('; ')}`,
|
||||
meta: {
|
||||
pass: allPass,
|
||||
reason: allPass
|
||||
? undefined
|
||||
: checks
|
||||
.filter((c) => !c.pass)
|
||||
.map((c) => c.detail)
|
||||
.join('; '),
|
||||
checks,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
245
src/workflows/roles/meta-tester.ts
Normal file
245
src/workflows/roles/meta-tester.ts
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Meta Tester role — e2e verification.
|
||||
*
|
||||
* Dynamic-imports the workflow coder just wrote, spins up a temp store,
|
||||
* ticks through a full lifecycle, and checks it reaches __end__.
|
||||
* On pass: git commit + push. On fail: back to coder.
|
||||
*
|
||||
* 小橘 🍊 (NEKO Team)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { MetaTesterMeta } from '../meta.js';
|
||||
import {
|
||||
type Role,
|
||||
type RoleResult,
|
||||
type WorkflowMessage,
|
||||
type WorkflowType,
|
||||
createStore,
|
||||
createWorkflowRule,
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
export function createMetaTesterRole(opts: {
|
||||
repoDir: string;
|
||||
/** git remote (auto-detect if omitted) */
|
||||
remote?: string;
|
||||
/** git branch (default: main) */
|
||||
branch?: string;
|
||||
/** max ticks for e2e run (default: 100;下限 20 以覆盖 ping-pong 静默 + 较长 workflow) */
|
||||
maxTicks?: number;
|
||||
}): Role<MetaTesterMeta> {
|
||||
const branch = opts.branch ?? 'main';
|
||||
const maxTicks = Math.max(opts.maxTicks ?? 100, 20);
|
||||
|
||||
return async (
|
||||
chain: WorkflowMessage[],
|
||||
): Promise<RoleResult<MetaTesterMeta>> => {
|
||||
const cwd = opts.repoDir;
|
||||
|
||||
// Step 0: Skip build — checker already verified build + unit tests
|
||||
// Go straight to e2e verification
|
||||
|
||||
// Step 1: Discover workflow files in engine src/workflows/
|
||||
const workflowDir = join(cwd, 'src', 'workflows');
|
||||
if (!existsSync(workflowDir)) {
|
||||
return {
|
||||
content: 'src/workflows/ 目录不存在',
|
||||
meta: { pass: false, reason: 'src/workflows/ not found' },
|
||||
};
|
||||
}
|
||||
|
||||
const files = readdirSync(workflowDir).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts'),
|
||||
);
|
||||
|
||||
// Step 2: Dynamic import each workflow, find WorkflowType exports
|
||||
const workflows: WorkflowType<any>[] = [];
|
||||
const importErrors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = join(workflowDir, file);
|
||||
try {
|
||||
const mod = await import(fullPath);
|
||||
for (const [key, val] of Object.entries(mod)) {
|
||||
// Direct WorkflowType export (e.g. `export const pingPong: WorkflowType`)
|
||||
if (val && typeof val === 'object' && 'name' in (val as any) && 'roles' in (val as any) && 'moderator' in (val as any)) {
|
||||
workflows.push(val as WorkflowType<any>);
|
||||
}
|
||||
// Factory function (e.g. `export function createWerewolfWorkflow()`)
|
||||
if (typeof val === 'function' && key.startsWith('create') && key.endsWith('Workflow')) {
|
||||
try {
|
||||
// Call with no args first (mock mode)
|
||||
const wf = val();
|
||||
if (wf && typeof wf === 'object' && 'name' in wf && 'roles' in wf && 'moderator' in wf) {
|
||||
workflows.push(wf as WorkflowType<any>);
|
||||
}
|
||||
} catch {
|
||||
// Factory needs args — skip (can't auto-test)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
importErrors.push(`${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return {
|
||||
content: `没有找到可测试的 WorkflowType\n\nimport errors:\n${importErrors.join('\n')}`,
|
||||
meta: { pass: false, reason: 'no testable workflows found' },
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: E2E — for each workflow, create temp store, tick through lifecycle
|
||||
const results: { name: string; pass: boolean; detail: string }[] = [];
|
||||
|
||||
for (const wf of workflows) {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), `pulse-tester-${wf.name}-`));
|
||||
const testStore = createStore({
|
||||
eventsDbPath: join(tmpDir, 'events.db'),
|
||||
objectsDir: join(tmpDir, 'objects'),
|
||||
});
|
||||
|
||||
try {
|
||||
const rule = createWorkflowRule(wf, testStore);
|
||||
|
||||
// Seed __start__
|
||||
const taskContent = `e2e test for ${wf.name}`;
|
||||
const hash = await testStore.putObject(taskContent);
|
||||
await testStore.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: `${wf.name}.__start__`,
|
||||
key: `e2e-test-${Date.now()}`,
|
||||
hash,
|
||||
});
|
||||
|
||||
// Tick until workflow quiesces (executed=[]) or maxTicks
|
||||
let completed = false;
|
||||
let tickCount = 0;
|
||||
let lastError: string | null = null;
|
||||
let didExecute = false;
|
||||
|
||||
for (let i = 0; i < maxTicks; i++) {
|
||||
tickCount++;
|
||||
try {
|
||||
const r = await rule.tick();
|
||||
if (r.executed.length === 0) {
|
||||
// No more work — if we executed at least once, workflow is done
|
||||
if (didExecute) {
|
||||
completed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
didExecute = true;
|
||||
} catch (err: any) {
|
||||
lastError = err.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather diagnostic info on failure
|
||||
let diagnostic = '';
|
||||
if (!completed) {
|
||||
try {
|
||||
const allEvents = await testStore.getAfter(0);
|
||||
const wfEvents = allEvents.filter((ev: any) => ev.kind.startsWith(`${wf.name}.`));
|
||||
const lastEvent = wfEvents[wfEvents.length - 1];
|
||||
const roles = wfEvents.map((ev: any) => ev.kind.replace(`${wf.name}.`, '')).join(' → ');
|
||||
diagnostic = `\n 事件链: ${roles}`;
|
||||
if (lastEvent) {
|
||||
diagnostic += `\n 最后事件: ${lastEvent.kind} (id=${lastEvent.id})`;
|
||||
if (lastEvent.meta) {
|
||||
try {
|
||||
const meta = JSON.parse(lastEvent.meta);
|
||||
diagnostic += `\n 最后 meta: ${JSON.stringify(meta)}`;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
diagnostic += `\n 总事件数: ${wfEvents.length}`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
results.push({ name: wf.name, pass: true, detail: `completed in ${tickCount} ticks` });
|
||||
} else if (lastError) {
|
||||
results.push({ name: wf.name, pass: false, detail: `error: ${lastError}${diagnostic}` });
|
||||
} else {
|
||||
results.push({ name: wf.name, pass: false, detail: `did not complete in ${tickCount} ticks${diagnostic}` });
|
||||
}
|
||||
} finally {
|
||||
await testStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
const allPass = results.every((r) => r.pass);
|
||||
const summary = results
|
||||
.map((r) => `${r.pass ? '✅' : '❌'} ${r.name}: ${r.detail}`)
|
||||
.join('\n');
|
||||
|
||||
if (!allPass) {
|
||||
return {
|
||||
content: `e2e 验证失败\n\n${summary}`,
|
||||
meta: { pass: false, reason: 'e2e verification failed' },
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: All pass — commit + push
|
||||
let commitHash: string | undefined;
|
||||
let pushed: boolean | undefined;
|
||||
|
||||
const exec = (cmd: string) =>
|
||||
execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30_000 }).trim();
|
||||
|
||||
try {
|
||||
const startMsg = chain.find((m) => m.role === '__start__');
|
||||
const firstLine = (startMsg?.content ?? '').split('\n')[0].slice(0, 60);
|
||||
const commitMsg = firstLine || 'meta workflow auto-commit';
|
||||
|
||||
exec('git add -A');
|
||||
|
||||
// Check if there's anything to commit
|
||||
try {
|
||||
exec('git diff --cached --quiet');
|
||||
// No changes — still pass, just no commit needed
|
||||
commitHash = exec('git rev-parse --short HEAD');
|
||||
pushed = false;
|
||||
} catch {
|
||||
// There are staged changes
|
||||
exec(
|
||||
`git commit -m "${commitMsg}" --author="小橘 <xiaoju@shazhou.work>"`,
|
||||
);
|
||||
commitHash = exec('git rev-parse --short HEAD');
|
||||
|
||||
// Auto-detect remote
|
||||
const remote = opts.remote ?? (() => {
|
||||
try {
|
||||
const remotes = exec('git remote').split('\n').filter(Boolean);
|
||||
return remotes[0] || null;
|
||||
} catch { return null; }
|
||||
})();
|
||||
|
||||
if (remote) {
|
||||
try {
|
||||
exec(`git push ${remote} ${branch} --no-verify`);
|
||||
pushed = true;
|
||||
} catch {
|
||||
pushed = false;
|
||||
}
|
||||
} else {
|
||||
pushed = false;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
commitHash = undefined;
|
||||
pushed = false;
|
||||
}
|
||||
|
||||
return {
|
||||
content: `e2e 验证通过\n\n${summary}\n\nCommit: ${commitHash ?? 'none'}\nPushed: ${pushed ? 'yes' : 'no'}`,
|
||||
meta: { pass: true, reason: 'e2e verification passed', commitHash, pushed },
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user