@zibby/workflow-templates 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser-test-automation/package.json +1 -1
- package/code-analysis/graph.js +5 -4
- package/code-analysis/nodes/analyze-ticket-node.js +9 -2
- package/code-analysis/nodes/generate-code-node.js +15 -9
- package/code-analysis/nodes/setup-node.js +50 -130
- package/code-analysis/nodes/utils/get-repo-path.js +32 -0
- package/code-analysis/package.json +1 -1
- package/code-analysis/prompts/setup.md +71 -0
- package/code-analysis/state.js +16 -0
- package/generate-test-cases/graph.mjs +11 -1
- package/generate-test-cases/nodes/setup-node.js +32 -130
- package/generate-test-cases/package.json +1 -1
- package/generate-test-cases/prompts/setup.md +50 -0
- package/generate-test-cases/state.js +12 -0
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"description": "Browser Test Automation \u2014 preflight + live Playwright execution + Playwright-script emission, driven by a Claude/Cursor agent.",
|
|
7
7
|
"main": "graph.mjs",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@zibby/core": "^0.
|
|
9
|
+
"@zibby/core": "^0.5.1",
|
|
10
10
|
"zod": "^3.23.0"
|
|
11
11
|
}
|
|
12
12
|
}
|
package/code-analysis/graph.js
CHANGED
|
@@ -21,17 +21,18 @@ function loadPrompt(filename) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Load prompt templates at graph definition time
|
|
24
|
+
const setupPrompt = loadPrompt('setup.md');
|
|
24
25
|
const analyzeTicketPrompt = loadPrompt('analyze-ticket.md');
|
|
25
26
|
const generateCodePrompt = loadPrompt('generate-code.md');
|
|
26
27
|
const generateTestCasesPrompt = loadPrompt('generate-test-cases.md');
|
|
27
28
|
|
|
28
29
|
export function buildAnalysisGraph(graph) {
|
|
29
30
|
graph.setStateSchema(analysisStateSchema);
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
graph
|
|
32
|
-
.addNode('setup', setupNode)
|
|
33
|
-
.addNode('analyze_ticket', analyzeTicketNode, {
|
|
34
|
-
prompt: analyzeTicketPrompt
|
|
33
|
+
.addNode('setup', setupNode, { prompt: setupPrompt })
|
|
34
|
+
.addNode('analyze_ticket', analyzeTicketNode, {
|
|
35
|
+
prompt: analyzeTicketPrompt,
|
|
35
36
|
})
|
|
36
37
|
.addConditionalNode('validation_check', {
|
|
37
38
|
condition: (state) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
|
-
import { z } from '
|
|
4
|
+
import { z, SKILLS } from '@zibby/core';
|
|
5
5
|
import { adfToText } from '@zibby/core/utils/adf-converter.js';
|
|
6
|
+
import { getRepoPath } from './utils/get-repo-path.js';
|
|
6
7
|
|
|
7
8
|
const generateId = () => randomBytes(16).toString('hex');
|
|
8
9
|
|
|
@@ -85,6 +86,12 @@ const AnalyzeTicketNodeOutputSchema = z.object({
|
|
|
85
86
|
|
|
86
87
|
export const analyzeTicketNode = {
|
|
87
88
|
name: 'analyze_ticket',
|
|
89
|
+
// skills:['jira'] makes the LLM aware of jira_search / jira_get_issue
|
|
90
|
+
// tools (handy when the ticketContext only carries a key and the agent
|
|
91
|
+
// needs to pull the full issue). Also drives the marketplace card's
|
|
92
|
+
// "Required Integrations: Jira" badge so users connect Jira before
|
|
93
|
+
// installing this workflow.
|
|
94
|
+
skills: [SKILLS.JIRA],
|
|
88
95
|
outputSchema: AnalyzeTicketNodeOutputSchema,
|
|
89
96
|
|
|
90
97
|
execute: async (context) => {
|
|
@@ -116,7 +123,7 @@ export const analyzeTicketNode = {
|
|
|
116
123
|
additionalContext: ticketContext.additionalContext || null,
|
|
117
124
|
repositories: Array.isArray(repos) ? repos.map(r => ({
|
|
118
125
|
name: r.name,
|
|
119
|
-
...detectProjectInfo(
|
|
126
|
+
...detectProjectInfo(getRepoPath(state, r.name))
|
|
120
127
|
})) : []
|
|
121
128
|
};
|
|
122
129
|
|
|
@@ -11,6 +11,7 @@ import Handlebars from 'handlebars';
|
|
|
11
11
|
import { invokeAgent } from '@zibby/core';
|
|
12
12
|
import { generatePRMeta } from './services/prMetaService.js';
|
|
13
13
|
import { adfToText } from '@zibby/core/utils/adf-converter.js';
|
|
14
|
+
import { getRepoPath } from './utils/get-repo-path.js';
|
|
14
15
|
import { z } from 'zod';
|
|
15
16
|
|
|
16
17
|
const CodeImplementationOutputSchema = z.object({
|
|
@@ -64,14 +65,16 @@ export function createCodeGenerationNode(options = {}) {
|
|
|
64
65
|
const _nodeConfig = nodeConfigs[nodeName] || {};
|
|
65
66
|
const analysis = state.analyze_ticket?.analysis;
|
|
66
67
|
|
|
67
|
-
// Build implementation prompt from template
|
|
68
|
+
// Build implementation prompt from template. Pass `state` so the
|
|
69
|
+
// prompt builder can resolve cloned repo paths from state.setup
|
|
70
|
+
// (populated by the LLM-driven setup node).
|
|
68
71
|
const implementPrompt = buildImplementationPrompt(
|
|
69
72
|
promptsDir,
|
|
70
73
|
ticketContext,
|
|
71
74
|
analysis,
|
|
72
75
|
commitAndPush,
|
|
73
76
|
repos,
|
|
74
|
-
|
|
77
|
+
state
|
|
75
78
|
);
|
|
76
79
|
|
|
77
80
|
console.log(`🚀 Running AI Agent to ${mode} changes with model: ${aiModel}...`);
|
|
@@ -96,7 +99,7 @@ export function createCodeGenerationNode(options = {}) {
|
|
|
96
99
|
commitMessage = `${ticketContext.ticketKey}: ${ticketContext.summary}\n\n${commitDesc}\n\nImplemented by Zibby Agent`;
|
|
97
100
|
|
|
98
101
|
for (const repo of reposToCommit) {
|
|
99
|
-
const repoPath =
|
|
102
|
+
const repoPath = getRepoPath(state, repo.name);
|
|
100
103
|
|
|
101
104
|
// Check if this repo has any changes
|
|
102
105
|
const status = await execCommand('git', ['status', '--porcelain'], repoPath);
|
|
@@ -153,9 +156,9 @@ export function createCodeGenerationNode(options = {}) {
|
|
|
153
156
|
: [{ name: '.' }];
|
|
154
157
|
|
|
155
158
|
for (const repo of reposToCheck) {
|
|
156
|
-
const currentRepoPath = repo.name === '.'
|
|
157
|
-
? workspace
|
|
158
|
-
:
|
|
159
|
+
const currentRepoPath = repo.name === '.'
|
|
160
|
+
? workspace
|
|
161
|
+
: getRepoPath(state, repo.name);
|
|
159
162
|
|
|
160
163
|
try {
|
|
161
164
|
let repoDiff, repoDiffStat, repoChangedFiles;
|
|
@@ -314,7 +317,8 @@ export const implementCodeNode = createCodeGenerationNode({
|
|
|
314
317
|
nodeName: 'implement_code'
|
|
315
318
|
});
|
|
316
319
|
|
|
317
|
-
function buildImplementationPrompt(promptsDir, ticketContext, analysis, isCommitting, repos,
|
|
320
|
+
function buildImplementationPrompt(promptsDir, ticketContext, analysis, isCommitting, repos, state) {
|
|
321
|
+
const workspace = state?.workspace;
|
|
318
322
|
const templatePath = join(promptsDir, 'generate-code.md');
|
|
319
323
|
if (!existsSync(templatePath)) {
|
|
320
324
|
throw new Error(`Template not found: ${templatePath}`);
|
|
@@ -342,9 +346,11 @@ function buildImplementationPrompt(promptsDir, ticketContext, analysis, isCommit
|
|
|
342
346
|
}
|
|
343
347
|
}
|
|
344
348
|
|
|
345
|
-
// Build repo metadata for the template
|
|
349
|
+
// Build repo metadata for the template. Use getRepoPath() so we
|
|
350
|
+
// detect language from wherever the setup node actually checked the
|
|
351
|
+
// repo out (state.setup.clonedRepos[].path).
|
|
346
352
|
const repositories = (repos && repos.length > 0) ? repos.map(r => {
|
|
347
|
-
const info = detectRepoLanguage(
|
|
353
|
+
const info = detectRepoLanguage(getRepoPath(state, r.name));
|
|
348
354
|
return { name: r.name, ...info };
|
|
349
355
|
}) : null;
|
|
350
356
|
|
|
@@ -1,142 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Setup Node -
|
|
3
|
-
*
|
|
2
|
+
* Setup Node — LLM-driven workspace bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* The agent receives the `git` skill (which exposes git_checkout +
|
|
5
|
+
* git_list_repos + git_explore as tools) plus a Bash tool, and is
|
|
6
|
+
* prompted to:
|
|
7
|
+
* 1. Clone each repo in state.repos. git_checkout auto-authenticates
|
|
8
|
+
* with GITHUB_TOKEN / GITLAB_TOKEN injected by the runner — works
|
|
9
|
+
* for both providers without the prompt needing to know which.
|
|
10
|
+
* 2. Initialize a baseline git repo at state.workspace so downstream
|
|
11
|
+
* diff tooling can compute generated changes.
|
|
12
|
+
* 3. Return the structured outputSchema below — downstream nodes read
|
|
13
|
+
* state.setup.clonedRepos[].path to find each repo locally.
|
|
14
|
+
*
|
|
15
|
+
* Why LLM-driven instead of raw `git clone` (the previous design):
|
|
16
|
+
* - User-customizable via prompts/setup.md (no JS edits needed)
|
|
17
|
+
* - Same node handles GitHub + GitLab transparently — the agent picks
|
|
18
|
+
* the right tool based on what's connected
|
|
19
|
+
* - Composable with state.repos changes: add a new repo to the project
|
|
20
|
+
* config and the agent clones it next run, no node code touched
|
|
4
21
|
*/
|
|
5
22
|
|
|
6
|
-
import {
|
|
7
|
-
import { join } from 'path';
|
|
8
|
-
import {
|
|
23
|
+
import { readFileSync, existsSync } from 'fs';
|
|
24
|
+
import { dirname, join } from 'path';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
import { z, SKILLS } from '@zibby/core';
|
|
27
|
+
|
|
28
|
+
// Prompt is loaded at module-import time from prompts/setup.md so users
|
|
29
|
+
// can edit it without touching this file. We attach it inline on the
|
|
30
|
+
// node config (rather than via graph.addNode(..., { prompt })) because
|
|
31
|
+
// the inline form is what other LLM nodes in this codebase use (see
|
|
32
|
+
// browser-test-automation/nodes/execute-live.mjs); same place agents
|
|
33
|
+
// look the prompt up at run time, no nodePrompts map indirection.
|
|
34
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const promptPath = join(__dirname, '..', 'prompts', 'setup.md');
|
|
36
|
+
const setupPrompt = existsSync(promptPath)
|
|
37
|
+
? readFileSync(promptPath, 'utf-8')
|
|
38
|
+
: '';
|
|
9
39
|
|
|
10
40
|
const SetupOutputSchema = z.object({
|
|
11
|
-
success: z.boolean(),
|
|
41
|
+
success: z.boolean().describe('true if all repos cloned + baseline initialized'),
|
|
12
42
|
clonedRepos: z.array(z.object({
|
|
13
|
-
name: z.string(),
|
|
14
|
-
path: z.string(),
|
|
15
|
-
isPrimary: z.boolean().optional()
|
|
16
|
-
})),
|
|
17
|
-
baselineCommit: z.string()
|
|
43
|
+
name: z.string().describe('Repo name (matches state.repos[].name)'),
|
|
44
|
+
path: z.string().describe('Absolute local path where the repo was cloned'),
|
|
45
|
+
isPrimary: z.boolean().optional().describe('True for the project\'s primary repo'),
|
|
46
|
+
})).describe('Every repo from state.repos must appear here once'),
|
|
47
|
+
baselineCommit: z.string().describe('git rev-parse HEAD at state.workspace after baseline commit'),
|
|
18
48
|
});
|
|
19
49
|
|
|
20
50
|
export const setupNode = {
|
|
21
51
|
name: 'setup',
|
|
52
|
+
// SKILLS.GIT gives the LLM git_checkout / git_list_repos / git_explore.
|
|
53
|
+
// The skill auto-auths against whichever provider is connected
|
|
54
|
+
// (GitHub OR GitLab) — having either one satisfies the requirement.
|
|
55
|
+
// Marketplace gating: the backend maps `git → github` for display
|
|
56
|
+
// (most-common-case heuristic); GitLab users hit the same workflow
|
|
57
|
+
// and gitSkill picks up GITLAB_TOKEN at runtime.
|
|
58
|
+
skills: [SKILLS.GIT],
|
|
22
59
|
outputSchema: SetupOutputSchema,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const { workspace, repos, githubToken } = state;
|
|
27
|
-
const gitlabToken = process.env.GITLAB_TOKEN || '';
|
|
28
|
-
const gitlabUrl = process.env.GITLAB_URL || '';
|
|
29
|
-
|
|
30
|
-
// DEBUG: Log token status
|
|
31
|
-
console.log(`🔑 GitHub Token: ${githubToken ? 'Present' : 'MISSING'}`);
|
|
32
|
-
console.log(`🔑 GitLab Token: ${gitlabToken ? 'Present' : 'MISSING'}`);
|
|
33
|
-
if (gitlabUrl) console.log(`🔑 GitLab URL: ${gitlabUrl}`);
|
|
34
|
-
|
|
35
|
-
// Log environment
|
|
36
|
-
console.log('Container: ECS Fargate');
|
|
37
|
-
console.log('Memory: 4GB');
|
|
38
|
-
console.log('CPU: 2 vCPU');
|
|
39
|
-
console.log('Tools: Node.js, Git, Cursor CLI, Zibby CLI');
|
|
40
|
-
console.log(`Working directory: ${workspace}`);
|
|
41
|
-
|
|
42
|
-
// Clone repositories
|
|
43
|
-
console.log('\n📦 Cloning repositories...');
|
|
44
|
-
|
|
45
|
-
const clonedRepos = [];
|
|
46
|
-
for (const repo of repos) {
|
|
47
|
-
console.log(`Cloning ${repo.name}...`);
|
|
48
|
-
|
|
49
|
-
const repoDir = join(workspace, repo.name);
|
|
50
|
-
|
|
51
|
-
// Use token for authentication based on provider
|
|
52
|
-
let cloneUrl = repo.url;
|
|
53
|
-
let cloneEnv = {};
|
|
54
|
-
const isGitlab = repo.provider === 'gitlab' || (gitlabUrl && repo.url.includes(new URL(gitlabUrl).host));
|
|
55
|
-
const isGithub = repo.provider === 'github' || repo.url.includes('github.com');
|
|
56
|
-
|
|
57
|
-
if (isGithub && githubToken) {
|
|
58
|
-
cloneUrl = repo.url.replace('https://github.com', `https://x-access-token:${githubToken}@github.com`);
|
|
59
|
-
cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
60
|
-
} else if (isGitlab && gitlabToken && gitlabUrl) {
|
|
61
|
-
try {
|
|
62
|
-
const gitlabHost = new URL(gitlabUrl).host;
|
|
63
|
-
cloneUrl = repo.url.replace(`https://${gitlabHost}`, `https://oauth2:${gitlabToken}@${gitlabHost}`);
|
|
64
|
-
} catch (e) {
|
|
65
|
-
console.warn(`⚠️ Failed to parse GITLAB_URL: ${e.message}`);
|
|
66
|
-
}
|
|
67
|
-
cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Shallow clone with progress output (async, non-blocking)
|
|
71
|
-
await execCommand(
|
|
72
|
-
`git clone --progress --depth 1 --branch ${repo.branch} "${cloneUrl}" "${repoDir}"`,
|
|
73
|
-
workspace,
|
|
74
|
-
cloneEnv
|
|
75
|
-
);
|
|
76
|
-
console.log(`✓ Cloned ${repo.name} on branch ${repo.branch}`);
|
|
77
|
-
|
|
78
|
-
clonedRepos.push({
|
|
79
|
-
name: repo.name,
|
|
80
|
-
path: repoDir,
|
|
81
|
-
isPrimary: repo.isPrimary
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Initialize git in workspace for diff tracking
|
|
86
|
-
await execCommand('git init', workspace);
|
|
87
|
-
await execCommand('git config user.email "zibby@agent.com"', workspace);
|
|
88
|
-
await execCommand('git config user.name "Zibby Agent"', workspace);
|
|
89
|
-
await execCommand('git add .', workspace);
|
|
90
|
-
await execCommand('git commit --allow-empty -m "baseline"', workspace);
|
|
91
|
-
|
|
92
|
-
console.log('✅ Environment ready');
|
|
93
|
-
|
|
94
|
-
const baselineCommit = await execCommand('git rev-parse HEAD', workspace);
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
success: true,
|
|
98
|
-
clonedRepos,
|
|
99
|
-
baselineCommit: baselineCommit.trim()
|
|
100
|
-
};
|
|
101
|
-
}
|
|
60
|
+
timeout: 5 * 60 * 1000, // 5min for cold clone of multiple repos + baseline
|
|
61
|
+
prompt: setupPrompt,
|
|
102
62
|
};
|
|
103
|
-
|
|
104
|
-
// Async version using spawn - streams output in real-time, doesn't block event loop
|
|
105
|
-
async function execCommand(command, cwd, env = {}) {
|
|
106
|
-
return new Promise((resolve, reject) => {
|
|
107
|
-
const proc = spawn(command, {
|
|
108
|
-
cwd,
|
|
109
|
-
shell: true,
|
|
110
|
-
env: Object.keys(env).length > 0 ? env : process.env
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
let stdout = '';
|
|
114
|
-
let stderr = '';
|
|
115
|
-
|
|
116
|
-
// Stream stdout as it comes (triggers middleware setInterval!)
|
|
117
|
-
proc.stdout.on('data', (data) => {
|
|
118
|
-
const output = data.toString();
|
|
119
|
-
stdout += output;
|
|
120
|
-
console.log(output.trimEnd());
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Stream stderr as it comes
|
|
124
|
-
proc.stderr.on('data', (data) => {
|
|
125
|
-
const output = data.toString();
|
|
126
|
-
stderr += output;
|
|
127
|
-
console.log(output.trimEnd());
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
proc.on('close', (code) => {
|
|
131
|
-
if (code !== 0) {
|
|
132
|
-
reject(new Error(`Command failed with exit code ${code}: ${command}`));
|
|
133
|
-
} else {
|
|
134
|
-
resolve(stdout || stderr || '');
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
proc.on('error', (err) => {
|
|
139
|
-
reject(new Error(`Command error: ${command} - ${err.message}`));
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the on-disk path for a repo.
|
|
5
|
+
*
|
|
6
|
+
* Source of truth: state.setup.clonedRepos[].path — populated by the
|
|
7
|
+
* LLM-driven setup node that calls git_checkout (via the `git` skill).
|
|
8
|
+
*
|
|
9
|
+
* Fallback: <workspace>/<name>. Kept so a workflow that never ran
|
|
10
|
+
* setup (e.g. zibby workflow run for unit testing a single node) still
|
|
11
|
+
* has a sensible path. Returns null if neither state.setup nor a
|
|
12
|
+
* workspace is available.
|
|
13
|
+
*
|
|
14
|
+
* Accepts either a raw state object OR a WorkflowState instance with
|
|
15
|
+
* .getAll(). Downstream nodes use both shapes across the template.
|
|
16
|
+
*
|
|
17
|
+
* @param {object | { getAll(): object }} stateOrAllState
|
|
18
|
+
* @param {string} repoName
|
|
19
|
+
* @returns {string | null}
|
|
20
|
+
*/
|
|
21
|
+
export function getRepoPath(stateOrAllState, repoName) {
|
|
22
|
+
if (!stateOrAllState || !repoName) return null;
|
|
23
|
+
const s = typeof stateOrAllState.getAll === 'function'
|
|
24
|
+
? stateOrAllState.getAll()
|
|
25
|
+
: stateOrAllState;
|
|
26
|
+
const cloned = Array.isArray(s.setup?.clonedRepos)
|
|
27
|
+
? s.setup.clonedRepos.find((r) => r?.name === repoName)
|
|
28
|
+
: null;
|
|
29
|
+
if (cloned?.path) return cloned.path;
|
|
30
|
+
if (s.workspace) return join(s.workspace, repoName);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"description": "Code Analysis \u2014 multi-node Jira-to-code workflow: analyze ticket \u2192 generate scoped code changes \u2192 emit covering test cases.",
|
|
7
7
|
"main": "graph.mjs",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@zibby/core": "^0.
|
|
9
|
+
"@zibby/core": "^0.5.1",
|
|
10
10
|
"axios": "^1.6.0",
|
|
11
11
|
"handlebars": "^4.7.8",
|
|
12
12
|
"zod": "^3.23.0"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Setup — clone repos + initialize baseline
|
|
2
|
+
|
|
3
|
+
You are setting up the workspace for code analysis. Two tasks:
|
|
4
|
+
|
|
5
|
+
## 1. Clone each repo in `state.repos`
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
state.repos = {{state.repos}}
|
|
9
|
+
state.workspace = {{state.workspace}}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For each entry, call `git_checkout`:
|
|
13
|
+
- `url`: the repo's `url` field
|
|
14
|
+
- `branch`: the repo's `branch` field (omit if not set — defaults to repo's default branch)
|
|
15
|
+
- `shallow`: true (the default — faster, depth=1)
|
|
16
|
+
|
|
17
|
+
`git_checkout` auto-authenticates against GitHub or GitLab using the
|
|
18
|
+
project's connected integration — you don't need to know which provider
|
|
19
|
+
the repo is hosted on. If the clone fails for one repo, continue with
|
|
20
|
+
the others and surface the failure in the final output.
|
|
21
|
+
|
|
22
|
+
After every repo is cloned, the `path` field that `git_checkout` returns
|
|
23
|
+
is what downstream nodes will read off state. Keep that path verbatim.
|
|
24
|
+
|
|
25
|
+
## 2. Initialize a baseline git repo at `state.workspace`
|
|
26
|
+
|
|
27
|
+
Downstream nodes (especially `generate_code`) diff your future changes
|
|
28
|
+
against this baseline. Use the Bash tool:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd {{state.workspace}}
|
|
32
|
+
git init
|
|
33
|
+
git config user.email "zibby@agent.com"
|
|
34
|
+
git config user.name "Zibby Agent"
|
|
35
|
+
git add .
|
|
36
|
+
git commit --allow-empty -m "baseline"
|
|
37
|
+
git rev-parse HEAD
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The output of `git rev-parse HEAD` is the `baselineCommit` you'll return.
|
|
41
|
+
|
|
42
|
+
## 3. Return JSON matching the outputSchema
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"success": true,
|
|
47
|
+
"clonedRepos": [
|
|
48
|
+
{ "name": "<repo name>", "path": "<absolute path from git_checkout>", "isPrimary": true }
|
|
49
|
+
],
|
|
50
|
+
"baselineCommit": "<HEAD sha from rev-parse>"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If any repo fails to clone, set `success: false` and still include the
|
|
55
|
+
ones that succeeded with their paths.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### Customizing this prompt
|
|
60
|
+
|
|
61
|
+
This file ships as a template default. Edit it freely to change setup
|
|
62
|
+
behavior — e.g.:
|
|
63
|
+
- Skip the baseline commit if you don't need diff tracking
|
|
64
|
+
- Add a `git_explore` call after cloning to give later nodes a structural
|
|
65
|
+
overview in state
|
|
66
|
+
- Filter `state.repos` (only clone repos matching a pattern)
|
|
67
|
+
- Run post-clone tooling (`npm install`, `bundle install`, etc) via Bash
|
|
68
|
+
|
|
69
|
+
The agent will follow whatever instructions you put here. The
|
|
70
|
+
outputSchema in `nodes/setup-node.js` is the contract — keep that field
|
|
71
|
+
shape so downstream nodes don't break.
|
package/code-analysis/state.js
CHANGED
|
@@ -70,6 +70,22 @@ export const analysisContextSchema = z.object({
|
|
|
70
70
|
|
|
71
71
|
nodeConfigs: z.record(z.string(), z.any()).optional()
|
|
72
72
|
.describe('Per-node configuration overrides — set at deploy time, not trigger time'),
|
|
73
|
+
|
|
74
|
+
// ── Node outputs (mid-graph, runner-populated) ────────────────────
|
|
75
|
+
// Each LLM node writes its outputSchema-validated result at
|
|
76
|
+
// state.<nodeName>. These are declared optional so initialState
|
|
77
|
+
// validates before any node has run.
|
|
78
|
+
|
|
79
|
+
setup: z.object({
|
|
80
|
+
success: z.boolean(),
|
|
81
|
+
clonedRepos: z.array(z.object({
|
|
82
|
+
name: z.string(),
|
|
83
|
+
path: z.string(),
|
|
84
|
+
isPrimary: z.boolean().optional(),
|
|
85
|
+
})),
|
|
86
|
+
baselineCommit: z.string(),
|
|
87
|
+
}).optional()
|
|
88
|
+
.describe('Output of the setup node — clone paths + baseline commit (downstream nodes read clonedRepos[].path)'),
|
|
73
89
|
});
|
|
74
90
|
|
|
75
91
|
// Derived: what the engine actually validates initialState against at
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* sync; the win is each template is independently editable.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { readFileSync, existsSync } from 'fs';
|
|
22
|
+
import { join, dirname } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
21
24
|
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
22
25
|
import { setupNode } from './nodes/setup-node.js';
|
|
23
26
|
import { generateTestCasesNode } from './nodes/generate-test-cases-node.js';
|
|
@@ -26,6 +29,13 @@ import {
|
|
|
26
29
|
generateTestCasesContextSchema,
|
|
27
30
|
} from './state.js';
|
|
28
31
|
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
function loadPrompt(filename) {
|
|
34
|
+
const path = join(__dirname, 'prompts', filename);
|
|
35
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
36
|
+
}
|
|
37
|
+
const setupPrompt = loadPrompt('setup.md');
|
|
38
|
+
|
|
29
39
|
export class GenerateTestCasesAgent extends WorkflowAgent {
|
|
30
40
|
buildGraph() {
|
|
31
41
|
const graph = new WorkflowGraph();
|
|
@@ -33,7 +43,7 @@ export class GenerateTestCasesAgent extends WorkflowAgent {
|
|
|
33
43
|
.setInputSchema(generateTestCasesInputSchema)
|
|
34
44
|
.setContextSchema(generateTestCasesContextSchema);
|
|
35
45
|
|
|
36
|
-
graph.addNode('setup', setupNode);
|
|
46
|
+
graph.addNode('setup', setupNode, { prompt: setupPrompt });
|
|
37
47
|
graph.addNode('generate_test_cases', generateTestCasesNode);
|
|
38
48
|
|
|
39
49
|
graph.setEntryPoint('setup');
|
|
@@ -1,142 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Setup Node -
|
|
3
|
-
*
|
|
2
|
+
* Setup Node — LLM-driven workspace bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors code-analysis/setup-node.js. The agent receives the `git`
|
|
5
|
+
* skill (git_checkout, git_list_repos, git_explore) plus Bash, and is
|
|
6
|
+
* prompted to clone each repo from state.repos and initialize a
|
|
7
|
+
* baseline commit at state.workspace for diff tracking. The output
|
|
8
|
+
* lands at state.setup.{clonedRepos[], baselineCommit} where downstream
|
|
9
|
+
* nodes read it.
|
|
10
|
+
*
|
|
11
|
+
* Prompt is loaded by graph.mjs from prompts/setup.md so users can
|
|
12
|
+
* customize setup behavior without editing this file.
|
|
4
13
|
*/
|
|
5
14
|
|
|
6
|
-
import {
|
|
7
|
-
import { join } from 'path';
|
|
8
|
-
import {
|
|
15
|
+
import { readFileSync, existsSync } from 'fs';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { z, SKILLS } from '@zibby/core';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const promptPath = join(__dirname, '..', 'prompts', 'setup.md');
|
|
22
|
+
const setupPrompt = existsSync(promptPath)
|
|
23
|
+
? readFileSync(promptPath, 'utf-8')
|
|
24
|
+
: '';
|
|
9
25
|
|
|
10
26
|
const SetupOutputSchema = z.object({
|
|
11
|
-
success: z.boolean(),
|
|
27
|
+
success: z.boolean().describe('true if all repos cloned + baseline initialized'),
|
|
12
28
|
clonedRepos: z.array(z.object({
|
|
13
|
-
name: z.string(),
|
|
14
|
-
path: z.string(),
|
|
15
|
-
isPrimary: z.boolean().optional()
|
|
16
|
-
})),
|
|
17
|
-
baselineCommit: z.string()
|
|
29
|
+
name: z.string().describe('Repo name (matches state.repos[].name)'),
|
|
30
|
+
path: z.string().describe('Absolute local path where the repo was cloned'),
|
|
31
|
+
isPrimary: z.boolean().optional().describe('True for the project\'s primary repo'),
|
|
32
|
+
})).describe('Every repo from state.repos must appear here once'),
|
|
33
|
+
baselineCommit: z.string().describe('git rev-parse HEAD at state.workspace after baseline commit'),
|
|
18
34
|
});
|
|
19
35
|
|
|
20
36
|
export const setupNode = {
|
|
21
37
|
name: 'setup',
|
|
38
|
+
// SKILLS.GIT gives the LLM clone tools that auto-auth against GitHub
|
|
39
|
+
// OR GitLab — either connected integration satisfies the workflow.
|
|
40
|
+
skills: [SKILLS.GIT],
|
|
22
41
|
outputSchema: SetupOutputSchema,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const { workspace, repos, githubToken } = state;
|
|
27
|
-
const gitlabToken = process.env.GITLAB_TOKEN || '';
|
|
28
|
-
const gitlabUrl = process.env.GITLAB_URL || '';
|
|
29
|
-
|
|
30
|
-
// DEBUG: Log token status
|
|
31
|
-
console.log(`🔑 GitHub Token: ${githubToken ? 'Present' : 'MISSING'}`);
|
|
32
|
-
console.log(`🔑 GitLab Token: ${gitlabToken ? 'Present' : 'MISSING'}`);
|
|
33
|
-
if (gitlabUrl) console.log(`🔑 GitLab URL: ${gitlabUrl}`);
|
|
34
|
-
|
|
35
|
-
// Log environment
|
|
36
|
-
console.log('Container: ECS Fargate');
|
|
37
|
-
console.log('Memory: 4GB');
|
|
38
|
-
console.log('CPU: 2 vCPU');
|
|
39
|
-
console.log('Tools: Node.js, Git, Cursor CLI, Zibby CLI');
|
|
40
|
-
console.log(`Working directory: ${workspace}`);
|
|
41
|
-
|
|
42
|
-
// Clone repositories
|
|
43
|
-
console.log('\n📦 Cloning repositories...');
|
|
44
|
-
|
|
45
|
-
const clonedRepos = [];
|
|
46
|
-
for (const repo of repos) {
|
|
47
|
-
console.log(`Cloning ${repo.name}...`);
|
|
48
|
-
|
|
49
|
-
const repoDir = join(workspace, repo.name);
|
|
50
|
-
|
|
51
|
-
// Use token for authentication based on provider
|
|
52
|
-
let cloneUrl = repo.url;
|
|
53
|
-
let cloneEnv = {};
|
|
54
|
-
const isGitlab = repo.provider === 'gitlab' || (gitlabUrl && repo.url.includes(new URL(gitlabUrl).host));
|
|
55
|
-
const isGithub = repo.provider === 'github' || repo.url.includes('github.com');
|
|
56
|
-
|
|
57
|
-
if (isGithub && githubToken) {
|
|
58
|
-
cloneUrl = repo.url.replace('https://github.com', `https://x-access-token:${githubToken}@github.com`);
|
|
59
|
-
cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
60
|
-
} else if (isGitlab && gitlabToken && gitlabUrl) {
|
|
61
|
-
try {
|
|
62
|
-
const gitlabHost = new URL(gitlabUrl).host;
|
|
63
|
-
cloneUrl = repo.url.replace(`https://${gitlabHost}`, `https://oauth2:${gitlabToken}@${gitlabHost}`);
|
|
64
|
-
} catch (e) {
|
|
65
|
-
console.warn(`⚠️ Failed to parse GITLAB_URL: ${e.message}`);
|
|
66
|
-
}
|
|
67
|
-
cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Shallow clone with progress output (async, non-blocking)
|
|
71
|
-
await execCommand(
|
|
72
|
-
`git clone --progress --depth 1 --branch ${repo.branch} "${cloneUrl}" "${repoDir}"`,
|
|
73
|
-
workspace,
|
|
74
|
-
cloneEnv
|
|
75
|
-
);
|
|
76
|
-
console.log(`✓ Cloned ${repo.name} on branch ${repo.branch}`);
|
|
77
|
-
|
|
78
|
-
clonedRepos.push({
|
|
79
|
-
name: repo.name,
|
|
80
|
-
path: repoDir,
|
|
81
|
-
isPrimary: repo.isPrimary
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Initialize git in workspace for diff tracking
|
|
86
|
-
await execCommand('git init', workspace);
|
|
87
|
-
await execCommand('git config user.email "zibby@agent.com"', workspace);
|
|
88
|
-
await execCommand('git config user.name "Zibby Agent"', workspace);
|
|
89
|
-
await execCommand('git add .', workspace);
|
|
90
|
-
await execCommand('git commit --allow-empty -m "baseline"', workspace);
|
|
91
|
-
|
|
92
|
-
console.log('✅ Environment ready');
|
|
93
|
-
|
|
94
|
-
const baselineCommit = await execCommand('git rev-parse HEAD', workspace);
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
success: true,
|
|
98
|
-
clonedRepos,
|
|
99
|
-
baselineCommit: baselineCommit.trim()
|
|
100
|
-
};
|
|
101
|
-
}
|
|
42
|
+
timeout: 5 * 60 * 1000,
|
|
43
|
+
prompt: setupPrompt,
|
|
102
44
|
};
|
|
103
|
-
|
|
104
|
-
// Async version using spawn - streams output in real-time, doesn't block event loop
|
|
105
|
-
async function execCommand(command, cwd, env = {}) {
|
|
106
|
-
return new Promise((resolve, reject) => {
|
|
107
|
-
const proc = spawn(command, {
|
|
108
|
-
cwd,
|
|
109
|
-
shell: true,
|
|
110
|
-
env: Object.keys(env).length > 0 ? env : process.env
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
let stdout = '';
|
|
114
|
-
let stderr = '';
|
|
115
|
-
|
|
116
|
-
// Stream stdout as it comes (triggers middleware setInterval!)
|
|
117
|
-
proc.stdout.on('data', (data) => {
|
|
118
|
-
const output = data.toString();
|
|
119
|
-
stdout += output;
|
|
120
|
-
console.log(output.trimEnd());
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Stream stderr as it comes
|
|
124
|
-
proc.stderr.on('data', (data) => {
|
|
125
|
-
const output = data.toString();
|
|
126
|
-
stderr += output;
|
|
127
|
-
console.log(output.trimEnd());
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
proc.on('close', (code) => {
|
|
131
|
-
if (code !== 0) {
|
|
132
|
-
reject(new Error(`Command failed with exit code ${code}: ${command}`));
|
|
133
|
-
} else {
|
|
134
|
-
resolve(stdout || stderr || '');
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
proc.on('error', (err) => {
|
|
139
|
-
reject(new Error(`Command error: ${command} - ${err.message}`));
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Setup — clone repos + initialize baseline
|
|
2
|
+
|
|
3
|
+
You are setting up the workspace for test-case generation. Two tasks:
|
|
4
|
+
|
|
5
|
+
## 1. Clone each repo in `state.repos`
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
state.repos = {{state.repos}}
|
|
9
|
+
state.workspace = {{state.workspace}}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For each entry, call `git_checkout`:
|
|
13
|
+
- `url`: the repo's `url` field
|
|
14
|
+
- `branch`: the repo's `branch` field (omit if not set)
|
|
15
|
+
- `shallow`: true (default)
|
|
16
|
+
|
|
17
|
+
`git_checkout` auto-authenticates against GitHub or GitLab using the
|
|
18
|
+
project's connected integration. If one repo fails, continue with the
|
|
19
|
+
others and surface the failure in the final output.
|
|
20
|
+
|
|
21
|
+
## 2. Initialize a baseline git repo at `state.workspace`
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd {{state.workspace}}
|
|
25
|
+
git init
|
|
26
|
+
git config user.email "zibby@agent.com"
|
|
27
|
+
git config user.name "Zibby Agent"
|
|
28
|
+
git add .
|
|
29
|
+
git commit --allow-empty -m "baseline"
|
|
30
|
+
git rev-parse HEAD
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `git rev-parse HEAD` output is the `baselineCommit` you'll return.
|
|
34
|
+
|
|
35
|
+
## 3. Return JSON matching the outputSchema
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"success": true,
|
|
40
|
+
"clonedRepos": [
|
|
41
|
+
{ "name": "<repo name>", "path": "<absolute path from git_checkout>", "isPrimary": true }
|
|
42
|
+
],
|
|
43
|
+
"baselineCommit": "<HEAD sha>"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
Edit this prompt to customize setup behavior — e.g. skip baseline,
|
|
50
|
+
filter repos, or run post-clone tooling (`npm install`, etc).
|
|
@@ -59,6 +59,18 @@ export const generateTestCasesContextSchema = z.object({
|
|
|
59
59
|
|
|
60
60
|
nodeConfigs: z.record(z.string(), z.any()).optional()
|
|
61
61
|
.describe('Per-node configuration overrides — set at deploy time, not trigger time'),
|
|
62
|
+
|
|
63
|
+
// ── Node outputs (mid-graph, runner-populated) ────────────────────
|
|
64
|
+
setup: z.object({
|
|
65
|
+
success: z.boolean(),
|
|
66
|
+
clonedRepos: z.array(z.object({
|
|
67
|
+
name: z.string(),
|
|
68
|
+
path: z.string(),
|
|
69
|
+
isPrimary: z.boolean().optional(),
|
|
70
|
+
})),
|
|
71
|
+
baselineCommit: z.string(),
|
|
72
|
+
}).optional()
|
|
73
|
+
.describe('Output of the setup node — clone paths + baseline commit'),
|
|
62
74
|
});
|
|
63
75
|
|
|
64
76
|
// Derived: full runtime state. Exported for tests + tooling.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zibby/workflow-templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases. Carved out of @zibby/core@0.4.6 so the engine ships without scaffolding payload.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@anthropic-ai/sdk": "^0.88.0",
|
|
60
|
-
"@zibby/agent-workflow": "^0.4.
|
|
60
|
+
"@zibby/agent-workflow": "^0.4.1",
|
|
61
61
|
"axios": "^1.15.0",
|
|
62
62
|
"handlebars": "^4.7.9",
|
|
63
63
|
"zod": "^3.23.0 || ^4.0.0"
|