From ab7ca6328c5914260d3e6e914d8d1c8d25c10309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 18 Apr 2026 14:57:21 +0000 Subject: [PATCH] =?UTF-8?q?#=20Task:=20=E7=BB=99=20meta=20workflow=20?= =?UTF-8?q?=E5=8A=A0=20gate=20role=20(pulse#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/workflows/meta.ts | 129 ++++++++++++ src/workflows/roles/meta-checker.ts | 129 ++++++++++++ src/workflows/roles/meta-coder-cursor.ts | 86 ++++++++ src/workflows/roles/meta-gate.test.ts | 71 +++++++ src/workflows/roles/meta-gate.ts | 92 +++++++++ src/workflows/roles/meta-tester.ts | 245 +++++++++++++++++++++++ 6 files changed, 752 insertions(+) create mode 100644 src/workflows/meta.ts create mode 100644 src/workflows/roles/meta-checker.ts create mode 100644 src/workflows/roles/meta-coder-cursor.ts create mode 100644 src/workflows/roles/meta-gate.test.ts create mode 100644 src/workflows/roles/meta-gate.ts create mode 100644 src/workflows/roles/meta-tester.ts diff --git a/src/workflows/meta.ts b/src/workflows/meta.ts new file mode 100644 index 0000000..8bb567d --- /dev/null +++ b/src/workflows/meta.ts @@ -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; + coder: Role; + checker: Role; + tester: Role; +}; + +// ── Moderator ────────────────────────────────────────────────── + +function metaModerator( + input: ModeratorInput, + _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 = async () => ({ + content: 'e2e stub coder', + meta: { filesChanged: [], testsPassed: true }, + }); + const stubChecker: Role = async () => ({ + content: 'ok', + meta: { pass: true, reason: 'e2e stub' }, + }); + const stubTester: Role = 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 { + const engineDir = fileURLToPath(new URL('../..', import.meta.url)); + return { + name: 'meta', + roles: roles ?? createDefaultMetaRoles(engineDir), + moderator: metaModerator, + }; +} diff --git a/src/workflows/roles/meta-checker.ts b/src/workflows/roles/meta-checker.ts new file mode 100644 index 0000000..382c910 --- /dev/null +++ b/src/workflows/roles/meta-checker.ts @@ -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 { + return async ( + _chain: WorkflowMessage[], + ): Promise> => { + 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: '约束检查通过' }, + }; + }; +} diff --git a/src/workflows/roles/meta-coder-cursor.ts b/src/workflows/roles/meta-coder-cursor.ts new file mode 100644 index 0000000..dcffe13 --- /dev/null +++ b/src/workflows/roles/meta-coder-cursor.ts @@ -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 { + return createAgentExecutorRole(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: 小橘 +- 只修改 $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, + }, + }); +} diff --git a/src/workflows/roles/meta-gate.test.ts b/src/workflows/roles/meta-gate.test.ts new file mode 100644 index 0000000..987011c --- /dev/null +++ b/src/workflows/roles/meta-gate.test.ts @@ -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('任务内容为空'); + }); +}); diff --git a/src/workflows/roles/meta-gate.ts b/src/workflows/roles/meta-gate.ts new file mode 100644 index 0000000..d86eb9d --- /dev/null +++ b/src/workflows/roles/meta-gate.ts @@ -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 { + return async ( + chain: WorkflowMessage[], + _topicId: string, + _store: PulseStore, + ): Promise> => { + 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, + }, + }; + }; +} diff --git a/src/workflows/roles/meta-tester.ts b/src/workflows/roles/meta-tester.ts new file mode 100644 index 0000000..d44125f --- /dev/null +++ b/src/workflows/roles/meta-tester.ts @@ -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 { + const branch = opts.branch ?? 'main'; + const maxTicks = Math.max(opts.maxTicks ?? 100, 20); + + return async ( + chain: WorkflowMessage[], + ): Promise> => { + 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[] = []; + 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); + } + // 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); + } + } 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="小橘 "`, + ); + 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 }, + }; + }; +}