@fieldwangai/agentflow 0.1.31 → 0.1.33
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/bin/lib/catalog-flows.mjs +33 -6
- package/bin/lib/composer-node-schema.mjs +7 -7
- package/bin/lib/composer-planner.mjs +5 -5
- package/bin/lib/git-worktree.mjs +248 -0
- package/bin/lib/gitlab-mr.mjs +174 -0
- package/bin/lib/locales/en.json +24 -0
- package/bin/lib/locales/zh.json +24 -0
- package/bin/lib/marketplace.mjs +230 -4
- package/bin/lib/paths.mjs +5 -0
- package/bin/lib/ui-server.mjs +298 -14
- package/bin/pipeline/pre-process-node.mjs +152 -11
- package/bin/pipeline/validate-flow.mjs +7 -17
- package/builtin/nodes/display_html.md +31 -0
- package/builtin/nodes/display_image.md +35 -0
- package/builtin/nodes/provide_bool.md +11 -0
- package/builtin/nodes/tool_git_checkout.md +8 -1
- package/builtin/nodes/tool_git_worktree_load.md +54 -0
- package/builtin/nodes/tool_git_worktree_unload.md +51 -0
- package/builtin/nodes/tool_gitlab_create_mr.md +113 -0
- package/builtin/web-ui/dist/assets/index-BWAb27N0.js +198 -0
- package/builtin/web-ui/dist/assets/index-DgfSfcjH.css +1 -0
- package/builtin/web-ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/builtin/web-ui/dist/assets/index-BVWwQpvg.css +0 -1
- package/builtin/web-ui/dist/assets/index-CvNy1n3f.js +0 -197
|
@@ -120,10 +120,15 @@ export function listFlowsJson(workspaceRoot, opts = {}) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/** 将 YAML 解析得到的 input/output 项转为 Web UI / 校验使用的槽位结构 */
|
|
123
|
+
function defaultShowOnNodeForSlot(slot) {
|
|
124
|
+
const type = String(slot?.type || "").trim().toLowerCase();
|
|
125
|
+
return Boolean(slot?.required) || type === "node";
|
|
126
|
+
}
|
|
127
|
+
|
|
123
128
|
function normalizeFrontmatterSlots(arr) {
|
|
124
129
|
if (!Array.isArray(arr)) return [];
|
|
125
130
|
return arr.map((item) => {
|
|
126
|
-
if (!item || typeof item !== "object") return { type: t("catalog.type_text"), name: "", default: "" };
|
|
131
|
+
if (!item || typeof item !== "object") return { type: t("catalog.type_text"), name: "", default: "", showOnNode: false };
|
|
127
132
|
const type = item.type != null ? String(item.type).trim() : t("catalog.type_text");
|
|
128
133
|
const name = item.name != null ? String(item.name).trim() : "";
|
|
129
134
|
let def = item.default !== undefined && item.default !== null ? item.default : item.value;
|
|
@@ -131,7 +136,7 @@ function normalizeFrontmatterSlots(arr) {
|
|
|
131
136
|
else if (typeof def !== "string") def = String(def);
|
|
132
137
|
const slot = { type, name, default: def };
|
|
133
138
|
if (item.required != null) slot.required = Boolean(item.required);
|
|
134
|
-
|
|
139
|
+
slot.showOnNode = item.showOnNode != null ? Boolean(item.showOnNode) : defaultShowOnNodeForSlot(slot);
|
|
135
140
|
return slot;
|
|
136
141
|
});
|
|
137
142
|
}
|
|
@@ -656,16 +661,38 @@ function resolveMarkdownNodeFile(workspaceRoot, nodeId, flowId, flowSource, opts
|
|
|
656
661
|
|
|
657
662
|
function readNodeUsage(workspaceRoot, nodeId, opts = {}) {
|
|
658
663
|
const usage = [];
|
|
664
|
+
const marketSpec = parseMarketplaceDefinitionId(nodeId);
|
|
659
665
|
for (const flow of listFlowsJson(workspaceRoot, opts)) {
|
|
660
666
|
const flowPath = getFlowYamlAbs(workspaceRoot, flow.id, flow.source || "user", { archived: Boolean(flow.archived), userId: opts.userId });
|
|
661
667
|
if (!flowPath.path) continue;
|
|
662
668
|
try {
|
|
663
669
|
const data = yaml.load(fs.readFileSync(flowPath.path, "utf-8"));
|
|
664
670
|
const instances = data && typeof data === "object" ? data.instances : null;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
671
|
+
const hits = [];
|
|
672
|
+
if (marketSpec) {
|
|
673
|
+
const deps = data && typeof data === "object" && data.dependencies && typeof data.dependencies === "object" ? data.dependencies : {};
|
|
674
|
+
const nodeDeps = Array.isArray(deps.nodes) ? deps.nodes : [];
|
|
675
|
+
if (nodeDeps.some((dep) => {
|
|
676
|
+
const parsed = typeof dep === "string"
|
|
677
|
+
? parseMarketplaceDefinitionId(dep.startsWith("marketplace:") ? dep : `marketplace:${dep}`)
|
|
678
|
+
: dep && typeof dep === "object"
|
|
679
|
+
? { id: dep.id, version: dep.version != null ? String(dep.version) : null }
|
|
680
|
+
: null;
|
|
681
|
+
return parsed && parsed.id === marketSpec.id && (!parsed.version || !marketSpec.version || parsed.version === marketSpec.version);
|
|
682
|
+
})) {
|
|
683
|
+
hits.push({ instanceId: "dependencies.nodes", label: "dependency" });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (instances && typeof instances === "object") {
|
|
687
|
+
hits.push(...Object.entries(instances)
|
|
688
|
+
.filter(([, inst]) => {
|
|
689
|
+
if (!inst) return false;
|
|
690
|
+
if (!marketSpec) return inst.definitionId === nodeId;
|
|
691
|
+
const parsed = parseMarketplaceDefinitionId(inst.definitionId);
|
|
692
|
+
return parsed && parsed.id === marketSpec.id && (!parsed.version || !marketSpec.version || parsed.version === marketSpec.version);
|
|
693
|
+
})
|
|
694
|
+
.map(([instanceId, inst]) => ({ instanceId, label: inst.label || instanceId })));
|
|
695
|
+
}
|
|
669
696
|
if (hits.length > 0) {
|
|
670
697
|
usage.push({ flowId: flow.id, flowSource: flow.source || "user", archived: Boolean(flow.archived), instances: hits });
|
|
671
698
|
}
|
|
@@ -114,13 +114,13 @@ export function buildNodeSchemaCompactSection() {
|
|
|
114
114
|
lines.push("- **`node`**(控制流连线):只表达「执行顺序」,**不携带业务数据**。串主链(Start→A→B→End)、汇合分支用它。槽位名通常是 `prev` / `next` / `prev1` / `next2` / `option_N`。⚠️ 业务字段绝不要标 `node`。");
|
|
115
115
|
lines.push("- **`text`**(短上下文 / 结论 / 路径串):上游把字符串结果(分析结论、用户输入、key 名、JSON 串)直接传给下游;下游 body / script 用 `${slotName}` 引用,apply 时原样替换。适合 < ~1KB 的内容。");
|
|
116
116
|
lines.push("- **`file`**(大块产物 / 上下文文件):上游把内容写到一个**文件**,下游通过 `${slotName}` 拿到的是**文件绝对路径**(不是内容)。下游需 Read 该路径取内容。适合报告 / todolist / 中间代码 / 截图等 > 1KB 或二进制。");
|
|
117
|
-
lines.push("- **`bool
|
|
117
|
+
lines.push("- **`bool`**(仅做分支判定):由 `provide_bool.value` / `control_toBool.prediction` / `control_agent_toBool.prediction` 输出,接到 `control_if.prediction` 输入;其它业务节点不要新增 `bool` 槽。");
|
|
118
118
|
lines.push("");
|
|
119
119
|
lines.push("**选 type 决策**:");
|
|
120
120
|
lines.push("- 想表达「下一步走谁」 → `node`");
|
|
121
121
|
lines.push("- 想传「短串/路径/key/JSON」 → `text`");
|
|
122
122
|
lines.push("- 想传「整篇文档/报告/JSON 文件/代码」 → `file`");
|
|
123
|
-
lines.push("- 想做「if 真假分支」 → `
|
|
123
|
+
lines.push("- 想做「if 真假分支」 → 手动开关用 `provide_bool`,确定性判断用 `control_toBool`,AI 判断用 `control_agent_toBool`,再接 `control_if` 的 bool 引脚");
|
|
124
124
|
lines.push("");
|
|
125
125
|
lines.push("## 内置节点 schema(权威,必须严格遵守)");
|
|
126
126
|
lines.push(
|
|
@@ -136,7 +136,7 @@ export function buildNodeSchemaCompactSection() {
|
|
|
136
136
|
lines.push("**硬性约束(违反则 validate-flow 失败):**");
|
|
137
137
|
lines.push("1. **固定槽位节点**(未带 ★):`input`/`output` 数组必须**完整复制**上表槽位(`type`、`name`、顺序、个数均不可改),仅可填写 `value`。");
|
|
138
138
|
lines.push("2. **可扩展节点**(带 ★):基础骨架不可删改,可在数组**末尾追加** type=`text` 或 `file` 的业务数据槽(按上方语义选 text 还是 file)。⚠️ 业务槽 type 必须 `text` 或 `file`,**绝对不能写 `node`**(node 仅控制流)。");
|
|
139
|
-
lines.push("3. `provide_*` 节点不得连入控制链(node→node 边),仅作数据源向下游 text/file 槽供值。");
|
|
139
|
+
lines.push("3. `provide_*` 节点不得连入控制链(node→node 边),仅作数据源向下游 text/file/bool 槽供值。");
|
|
140
140
|
lines.push("4. 边连接时 `sourceHandle: output-N` 与 `targetHandle: input-N` 的索引必须对应**同一 type**;type 不一致禁止连线(text 不能接 file,node 不能接 text)。");
|
|
141
141
|
lines.push("5. **YAML 多行字符串必须用 `|` 块标量。** 写 `script` / `body` / `value` 等字符串字段时,只要内容含 `: `、`\"`、`'`、`#`、换行、shell 操作符,**强制**使用 `|` 块。");
|
|
142
142
|
cachedCompact = lines.join("\n");
|
|
@@ -157,7 +157,7 @@ export function buildNodeSchemaPromptSection() {
|
|
|
157
157
|
lines.push("- **`node`**(控制流连线):只表达「执行顺序」,**不携带业务数据**。串主链、汇合分支用它。槽位名通常是 `prev` / `next` / `prev1` / `next2` / `option_N`。⚠️ 业务字段绝不要标 `node`。");
|
|
158
158
|
lines.push("- **`text`**(短上下文 / 结论 / 路径串):上游把字符串结果(分析结论、用户输入、key 名、JSON 串)直接传给下游;下游 body / script 用 `${slotName}` 引用,apply 时原样替换。适合 < ~1KB 的内容。");
|
|
159
159
|
lines.push("- **`file`**(大块产物 / 上下文文件):上游把内容写到一个**文件**,下游通过 `${slotName}` 拿到的是**文件绝对路径**(不是内容)。下游需 Read 该路径取内容。适合报告 / todolist / 中间代码 / 截图等 > 1KB 或二进制。");
|
|
160
|
-
lines.push("- **`bool
|
|
160
|
+
lines.push("- **`bool`**(仅做分支判定):由 `provide_bool.value` / `control_toBool.prediction` / `control_agent_toBool.prediction` 输出,接到 `control_if.prediction` 输入;其它业务节点不要新增 `bool` 槽。");
|
|
161
161
|
lines.push("");
|
|
162
162
|
lines.push("## 内置节点 schema(权威,必须严格遵守)");
|
|
163
163
|
lines.push(
|
|
@@ -177,7 +177,7 @@ export function buildNodeSchemaPromptSection() {
|
|
|
177
177
|
lines.push(
|
|
178
178
|
"2. **可扩展节点**(带 ★:agent_subAgent / tool_nodejs / tool_user_check):" +
|
|
179
179
|
"上表槽位为**基础骨架不可删改**(`prev`/`next` 等控制槽与 schema 已有数据槽的 `type`/`name`/顺序保持一致);" +
|
|
180
|
-
"可在数组**末尾追加** type=`text` 或 `file` 的业务数据槽(`bool` 仅 control_toBool / control_agent_toBool / control_if 使用,禁止他处出现)," +
|
|
180
|
+
"可在数组**末尾追加** type=`text` 或 `file` 的业务数据槽(`bool` 仅 provide_bool / control_toBool / control_agent_toBool / control_if 使用,禁止他处出现)," +
|
|
181
181
|
"命名应与上下游语义对齐(如 `fromapp`、`analysis`、`compile_result`、`result`),便于阶段三连线。"
|
|
182
182
|
);
|
|
183
183
|
lines.push(
|
|
@@ -186,7 +186,7 @@ export function buildNodeSchemaPromptSection() {
|
|
|
186
186
|
" 不要因为 schema 表里 `prev:node` 就惯性给 `fromapp`/`toapp`/`page_name` 也写 `node`——那意味着「控制流连线」,下游会报「边类型不一致」。"
|
|
187
187
|
);
|
|
188
188
|
lines.push(
|
|
189
|
-
"3. `provide_*` 节点不得连入控制链(node→node 边),仅作数据源向下游 text/file 槽供值。"
|
|
189
|
+
"3. `provide_*` 节点不得连入控制链(node→node 边),仅作数据源向下游 text/file/bool 槽供值。"
|
|
190
190
|
);
|
|
191
191
|
lines.push(
|
|
192
192
|
"4. 边连接时 `sourceHandle: output-N` 与 `targetHandle: input-N` 的索引必须对应同一 type;type 不一致禁止连线。"
|
|
@@ -214,7 +214,7 @@ export function buildNodeSchemaPromptSection() {
|
|
|
214
214
|
lines.push("│ └─ type: text ✅");
|
|
215
215
|
lines.push("├─ 文件绝对路径(todolist.json、conversion_result.md、screenshot.png …)");
|
|
216
216
|
lines.push("│ └─ type: file ✅");
|
|
217
|
-
lines.push("└─
|
|
217
|
+
lines.push("└─ 二元判定值(provide_bool.value、control_toBool / control_agent_toBool 的 prediction、control_if 的 prediction)");
|
|
218
218
|
lines.push(" └─ type: bool ✅(其他节点禁用)");
|
|
219
219
|
lines.push("");
|
|
220
220
|
lines.push("⛔ 任何业务数据槽都**不可**写 type: node");
|
|
@@ -123,7 +123,7 @@ function buildPhasedSystemPrompt(phaseName, intents) {
|
|
|
123
123
|
为**每个**需要完善的 instance 生成独立 agent 步骤(或确定性 script 步骤):
|
|
124
124
|
- **agent_subAgent**:若 body 已是可执行 prompt 则跳过;否则编写**准确、可执行**的 \`body\`(提示词/规则/输入输出占位 \`\${...}\`)。**复杂度用 "simple"**。
|
|
125
125
|
- **tool_nodejs**:若 \`script\` 字段已是完整命令且 scripts/ 下脚本已存在 → 跳过;否则在 **scripts/** 子目录创建 Node 脚本(\`scripts/<instanceId>.mjs\`),\`script\` 写入完整 \`node ...\` 调用并用 \`\${}\` 引用槽位。**引用 scripts/ 必须用 \`\${flowDir}/scripts/xxx.mjs\`**,不要写 \`\${workspaceRoot}/.workspace/agentflow/pipelines/\${flowName}/scripts/...\`(flow 可能安装到 \`~/agentflow/pipelines\` 或 builtin,硬编码 workspace 路径会找不到脚本)。**不要**给 \`\${workspaceRoot}\`、\`\${runDir}\` 等外包双引号(已自动 shell-quote)。**禁止**仅 body 自然语言无 script。
|
|
126
|
-
- **control_toBool / provide_str / provide_file** 等:按规格书补齐空的 \`body\` 或 output \`value\`。
|
|
126
|
+
- **control_toBool / provide_str / provide_file / provide_bool** 等:按规格书补齐空的 \`body\` 或 output \`value\`。
|
|
127
127
|
- **引脚补漏**:核对 body/script 中每个 \`\${X}\` 是否对应实际槽位 name;缺槽就在末尾追加(type 必为 \`text\` 或 \`file\`,**绝不写 node**),多余的不要动(可能是阶段三连线用)。
|
|
128
128
|
- **不得删改基础控制槽**(\`prev\`/\`next\` 的 type/name 与顺序);**固定槽位节点**(control_* / provide_* / tool_load_key/save_key/get_env / tool_print / tool_user_ask)的 input/output 结构永不修改。
|
|
129
129
|
|
|
@@ -146,9 +146,9 @@ function buildPhasedSystemPrompt(phaseName, intents) {
|
|
|
146
146
|
2. **引脚语义审查 checklist**(每节点过一遍,发现问题修正):
|
|
147
147
|
a. **同 output 多消费者冲突**:一个 output 槽被两条边消费且消费方语义矛盾(如同时供 \`control_toBool.value\`(要 true/false 单行)和 \`agent.input\`(要详细内容)→ 必须**拆成两个 output 槽**(如 \`result:text\` + \`report:file\`)
|
|
148
148
|
b. **text vs file 错配**:内容超过 ~1KB 或为多行报告/日志/源码 → 应是 \`file\`;只是路径串/key/JSON 短串 → 应是 \`text\`
|
|
149
|
-
c. **bool 误用**:\`bool\` 槽只允许出现在 \`control_toBool.prediction\` / \`control_agent_toBool.prediction\`(out) 与 \`control_if.prediction\`(in)
|
|
149
|
+
c. **bool 误用**:\`bool\` 槽只允许出现在 \`provide_bool.value\`、\`control_toBool.prediction\` / \`control_agent_toBool.prediction\`(out) 与 \`control_if.prediction\`(in),其它任何节点禁用
|
|
150
150
|
d. **节点类型错配**:发现 \`tool_nodejs\` 实际做的是非确定性任务(代码翻译/源码理解/创意生成)→ 改 \`definitionId: agent_subAgent\` + 删 script + 把要求写到 body
|
|
151
|
-
e. **provide_* 类型对齐**:\`provide_str\` 必须 \`output[0].type=text\`;\`provide_file\` 必须 \`output[0].type=file\`
|
|
151
|
+
e. **provide_* 类型对齐**:\`provide_str\` 必须 \`output[0].type=text\`;\`provide_file\` 必须 \`output[0].type=file\`;\`provide_bool\` 必须 \`output[0].type=bool\`
|
|
152
152
|
3. **ui.nodePositions**:按 \`reference/flow-layout.md\` 优化布局(主链 x 递增、分支 y 错开、避免一条线)。
|
|
153
153
|
4. 完成后应能通过 validate-flow;可用 add-edge、update-position 等 script 步骤,必要时用 agent 步骤处理复杂拓扑。
|
|
154
154
|
|
|
@@ -648,9 +648,9 @@ function buildPhaseCliGuide(phaseIndex) {
|
|
|
648
648
|
2. **引脚语义审查 checklist**(每节点过一遍):
|
|
649
649
|
a. **同 output 多消费者冲突**:一个 output 同时供给两个语义矛盾的下游(如 \`toBool.value\` 要单行 true/false 与 \`agent.input\` 要详细内容)→ 拆成两个 output 槽
|
|
650
650
|
b. **text/file 错配**:内容超 ~1KB 或多行报告/源码 → 应是 \`file\`;只是路径串/key/JSON 短串 → 应是 \`text\`
|
|
651
|
-
c. **bool 误用**:\`bool\` 槽只允许 \`control_toBool.prediction\` / \`control_agent_toBool.prediction\`(out) → \`control_if.prediction\`(in),其它禁用
|
|
651
|
+
c. **bool 误用**:\`bool\` 槽只允许 \`provide_bool.value\`、\`control_toBool.prediction\` / \`control_agent_toBool.prediction\`(out) → \`control_if.prediction\`(in),其它禁用
|
|
652
652
|
d. **节点类型错配**:\`tool_nodejs\` 实际做非确定性任务(代码翻译/源码理解/创意生成)→ 改 \`definitionId: agent_subAgent\` + 删 script + 写 body
|
|
653
|
-
e. **provide_* 类型对齐**:\`provide_str.output[0].type\` 必为 \`text\`;\`provide_file.output[0].type\` 必为 \`file\`
|
|
653
|
+
e. **provide_* 类型对齐**:\`provide_str.output[0].type\` 必为 \`text\`;\`provide_file.output[0].type\` 必为 \`file\`;\`provide_bool.output[0].type\` 必为 \`bool\`
|
|
654
654
|
3. **优化 ui.nodePositions**(参考 flow-layout.md:主链 x 递增、分支 y 错开)。
|
|
655
655
|
4. 完成后须能通过 **validate-flow**;本轮结束后系统会自动校验并尝试修复。`;
|
|
656
656
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
|
|
5
|
+
export function runGit(args, cwd) {
|
|
6
|
+
return spawnSync("git", args, {
|
|
7
|
+
cwd,
|
|
8
|
+
encoding: "utf-8",
|
|
9
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseJsonObject(raw) {
|
|
14
|
+
if (raw == null) return null;
|
|
15
|
+
if (typeof raw === "object" && !Array.isArray(raw)) return raw;
|
|
16
|
+
const text = String(raw || "").trim();
|
|
17
|
+
if (!text) return null;
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(text);
|
|
20
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function gitOrThrow(args, cwd, label) {
|
|
27
|
+
const result = runGit(args, cwd);
|
|
28
|
+
if (result.status !== 0) {
|
|
29
|
+
throw new Error(`${label || "git"} failed: ${result.stderr || result.stdout || result.error?.message || "unknown error"}`);
|
|
30
|
+
}
|
|
31
|
+
return result.stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readGitRemoteUrl(repoPath, remote = "origin") {
|
|
35
|
+
const result = runGit(["remote", "get-url", remote], repoPath);
|
|
36
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseRemoteUrl(remoteUrl) {
|
|
40
|
+
const raw = String(remoteUrl || "").trim().replace(/\.git$/i, "");
|
|
41
|
+
if (!raw) return { host: "", projectPath: "", provider: "" };
|
|
42
|
+
let host = "";
|
|
43
|
+
let projectPath = "";
|
|
44
|
+
const scpLike = raw.match(/^[^@]+@([^:]+):(.+)$/);
|
|
45
|
+
if (scpLike) {
|
|
46
|
+
host = scpLike[1];
|
|
47
|
+
projectPath = scpLike[2].replace(/^\/+/, "");
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(raw);
|
|
51
|
+
host = u.host;
|
|
52
|
+
projectPath = u.pathname.replace(/^\/+/, "");
|
|
53
|
+
} catch {
|
|
54
|
+
projectPath = raw;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const provider = host && /gitlab|git\.sysop/i.test(host) ? "gitlab" : "";
|
|
58
|
+
return { host, projectPath, provider };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeGitContext(raw) {
|
|
62
|
+
const parsed = parseJsonObject(raw);
|
|
63
|
+
if (!parsed) return null;
|
|
64
|
+
return {
|
|
65
|
+
version: 1,
|
|
66
|
+
repoPath: parsed.repoPath ? path.resolve(String(parsed.repoPath)) : "",
|
|
67
|
+
worktreePath: parsed.worktreePath ? path.resolve(String(parsed.worktreePath)) : "",
|
|
68
|
+
branch: parsed.branch ? String(parsed.branch) : "",
|
|
69
|
+
commit: parsed.commit ? String(parsed.commit) : "",
|
|
70
|
+
remote: parsed.remote ? String(parsed.remote) : "",
|
|
71
|
+
remoteUrl: parsed.remoteUrl ? String(parsed.remoteUrl) : "",
|
|
72
|
+
provider: parsed.provider ? String(parsed.provider) : "",
|
|
73
|
+
host: parsed.host ? String(parsed.host) : "",
|
|
74
|
+
projectPath: parsed.projectPath ? String(parsed.projectPath) : "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildGitContext({ repoPath, worktreePath = "", branch = "", commit = "", remote = "origin", remoteUrl = "" }) {
|
|
79
|
+
const repoRoot = resolveGitRepoRoot(repoPath);
|
|
80
|
+
const actualRemote = String(remote || "origin").trim() || "origin";
|
|
81
|
+
const actualRemoteUrl = remoteUrl || readGitRemoteUrl(repoRoot, actualRemote);
|
|
82
|
+
const remoteMeta = parseRemoteUrl(actualRemoteUrl);
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
repoPath: repoRoot,
|
|
86
|
+
worktreePath: worktreePath ? path.resolve(worktreePath) : "",
|
|
87
|
+
branch: String(branch || ""),
|
|
88
|
+
commit: String(commit || ""),
|
|
89
|
+
remote: actualRemote,
|
|
90
|
+
remoteUrl: actualRemoteUrl,
|
|
91
|
+
provider: remoteMeta.provider,
|
|
92
|
+
host: remoteMeta.host,
|
|
93
|
+
projectPath: remoteMeta.projectPath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sanitizeWorktreeName(raw) {
|
|
98
|
+
return String(raw || "worktree")
|
|
99
|
+
.trim()
|
|
100
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
101
|
+
.replace(/^-+|-+$/g, "")
|
|
102
|
+
|| "worktree";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resolveGitRepoRoot(repoPath) {
|
|
106
|
+
const raw = String(repoPath || "").trim();
|
|
107
|
+
if (!raw) throw new Error("repoPath is required");
|
|
108
|
+
const abs = path.resolve(raw);
|
|
109
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
110
|
+
throw new Error(`repoPath directory not found: ${abs}`);
|
|
111
|
+
}
|
|
112
|
+
return path.resolve(gitOrThrow(["rev-parse", "--show-toplevel"], abs, "git rev-parse"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function currentGitBranch(repoRoot) {
|
|
116
|
+
const branch = gitOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot, "git rev-parse branch");
|
|
117
|
+
return branch === "HEAD" ? "" : branch;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function currentGitCommit(repoRoot) {
|
|
121
|
+
return gitOrThrow(["rev-parse", "HEAD"], repoRoot, "git rev-parse commit");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function listGitWorktrees(repoRoot) {
|
|
125
|
+
const text = gitOrThrow(["worktree", "list", "--porcelain"], repoRoot, "git worktree list");
|
|
126
|
+
const entries = [];
|
|
127
|
+
let current = null;
|
|
128
|
+
for (const line of text.split(/\r?\n/)) {
|
|
129
|
+
if (!line.trim()) {
|
|
130
|
+
if (current) entries.push(current);
|
|
131
|
+
current = null;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const [key, ...rest] = line.split(" ");
|
|
135
|
+
const value = rest.join(" ").trim();
|
|
136
|
+
if (key === "worktree") {
|
|
137
|
+
if (current) entries.push(current);
|
|
138
|
+
current = { path: path.resolve(value), head: "", branchRef: "", branch: "" };
|
|
139
|
+
} else if (current && key === "HEAD") {
|
|
140
|
+
current.head = value;
|
|
141
|
+
} else if (current && key === "branch") {
|
|
142
|
+
current.branchRef = value;
|
|
143
|
+
current.branch = value.replace(/^refs\/heads\//, "");
|
|
144
|
+
} else if (current && key === "detached") {
|
|
145
|
+
current.branch = "DETACHED";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (current) entries.push(current);
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function findRegisteredWorktree(repoRoot, worktreePath) {
|
|
153
|
+
const target = path.resolve(worktreePath);
|
|
154
|
+
return listGitWorktrees(repoRoot).find((entry) => path.resolve(entry.path) === target) || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function branchExists(repoRoot, branch) {
|
|
158
|
+
const result = runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot);
|
|
159
|
+
return result.status === 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultWorktreePath(pipelineWorkspace, repoRoot, branch) {
|
|
163
|
+
const repoName = sanitizeWorktreeName(path.basename(repoRoot));
|
|
164
|
+
const commit = currentGitCommit(repoRoot);
|
|
165
|
+
const currentBranch = currentGitBranch(repoRoot);
|
|
166
|
+
const refLabel = branch || currentBranch || commit.slice(0, 12);
|
|
167
|
+
return path.join(path.resolve(pipelineWorkspace), ".workspace", "agentflow", "worktrees", repoName, sanitizeWorktreeName(refLabel));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function actualWorktreeBranch(worktreePath) {
|
|
171
|
+
const branch = gitOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath, "git rev-parse worktree branch");
|
|
172
|
+
return branch === "HEAD" ? "DETACHED" : branch;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function loadGitWorktree({ repoPath, branch = "", worktreePath = "", pipelineWorkspace }) {
|
|
176
|
+
const repoRoot = resolveGitRepoRoot(repoPath);
|
|
177
|
+
const wantedBranch = String(branch || "").trim();
|
|
178
|
+
const target = path.resolve(worktreePath || defaultWorktreePath(pipelineWorkspace || repoRoot, repoRoot, wantedBranch));
|
|
179
|
+
|
|
180
|
+
if (wantedBranch && !branchExists(repoRoot, wantedBranch)) {
|
|
181
|
+
throw new Error(`branch does not exist in repoPath: ${wantedBranch}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const registered = findRegisteredWorktree(repoRoot, target);
|
|
185
|
+
if (fs.existsSync(target)) {
|
|
186
|
+
if (!registered) {
|
|
187
|
+
throw new Error(`worktreePath exists but is not registered for repoPath: ${target}`);
|
|
188
|
+
}
|
|
189
|
+
if (wantedBranch && registered.branch !== wantedBranch) {
|
|
190
|
+
throw new Error(`worktreePath branch mismatch: expected ${wantedBranch}, got ${registered.branch || "DETACHED"}`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
194
|
+
const args = wantedBranch
|
|
195
|
+
? ["worktree", "add", target, wantedBranch]
|
|
196
|
+
: ["worktree", "add", "--detach", target, "HEAD"];
|
|
197
|
+
const result = runGit(args, repoRoot);
|
|
198
|
+
if (result.status !== 0) {
|
|
199
|
+
throw new Error(`git worktree add failed: ${result.stderr || result.stdout}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const actualBranch = actualWorktreeBranch(target);
|
|
204
|
+
const commit = currentGitCommit(target);
|
|
205
|
+
return {
|
|
206
|
+
repoRoot,
|
|
207
|
+
worktreePath: target,
|
|
208
|
+
branch: actualBranch,
|
|
209
|
+
commit,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function unloadGitWorktree({ repoPath, worktreePath, force = false, prune = true }) {
|
|
214
|
+
const repoRoot = resolveGitRepoRoot(repoPath);
|
|
215
|
+
const targetRaw = String(worktreePath || "").trim();
|
|
216
|
+
if (!targetRaw) throw new Error("worktreePath is required");
|
|
217
|
+
const target = path.resolve(targetRaw);
|
|
218
|
+
const registered = findRegisteredWorktree(repoRoot, target);
|
|
219
|
+
if (!registered) {
|
|
220
|
+
throw new Error(`worktreePath is not registered for repoPath: ${target}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!force && fs.existsSync(target)) {
|
|
224
|
+
const status = runGit(["status", "--porcelain"], target);
|
|
225
|
+
if (status.status !== 0) throw new Error(`git status failed: ${status.stderr || status.stdout}`);
|
|
226
|
+
if (status.stdout.trim()) {
|
|
227
|
+
throw new Error("worktree has uncommitted or untracked changes; set force=true to remove it");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const args = ["worktree", "remove"];
|
|
232
|
+
if (force) args.push("--force");
|
|
233
|
+
args.push(target);
|
|
234
|
+
const remove = runGit(args, repoRoot);
|
|
235
|
+
if (remove.status !== 0) {
|
|
236
|
+
throw new Error(`git worktree remove failed: ${remove.stderr || remove.stdout}`);
|
|
237
|
+
}
|
|
238
|
+
if (prune) {
|
|
239
|
+
const pruneResult = runGit(["worktree", "prune"], repoRoot);
|
|
240
|
+
if (pruneResult.status !== 0) throw new Error(`git worktree prune failed: ${pruneResult.stderr || pruneResult.stdout}`);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
repoRoot,
|
|
244
|
+
worktreePath: target,
|
|
245
|
+
removed: true,
|
|
246
|
+
message: `removed worktree: ${target}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
import { buildGitContext, currentGitBranch, currentGitCommit, normalizeGitContext, resolveGitRepoRoot, runGit } from "./git-worktree.mjs";
|
|
4
|
+
|
|
5
|
+
function gitOrThrow(args, cwd, label) {
|
|
6
|
+
const result = runGit(args, cwd);
|
|
7
|
+
if (result.status !== 0) {
|
|
8
|
+
throw new Error(`${label || "git"} failed: ${result.stderr || result.stdout || result.error?.message || "unknown error"}`);
|
|
9
|
+
}
|
|
10
|
+
return result.stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isTruthy(value, defaultValue = false) {
|
|
14
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
15
|
+
if (!text) return Boolean(defaultValue);
|
|
16
|
+
return text === "true" || text === "1" || text === "yes" || text === "y" || text === "on";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveToken(env, tokenEnv) {
|
|
20
|
+
const names = String(tokenEnv || "")
|
|
21
|
+
.split(/[\s,]+/)
|
|
22
|
+
.map((name) => name.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
const candidates = names.length > 0 ? names : ["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"];
|
|
25
|
+
for (const name of candidates) {
|
|
26
|
+
const value = env?.[name];
|
|
27
|
+
if (value != null && String(value).trim()) return { token: String(value), tokenEnv: name };
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`GitLab token not found in env: ${candidates.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function apiBaseFromContext(gitContext, explicit) {
|
|
33
|
+
const raw = String(explicit || "").trim().replace(/\/+$/, "");
|
|
34
|
+
if (raw) return raw.endsWith("/api/v4") ? raw : `${raw}/api/v4`;
|
|
35
|
+
const host = String(gitContext?.host || "").trim();
|
|
36
|
+
if (!host) throw new Error("gitlabApiBase or gitContext.host is required");
|
|
37
|
+
return `https://${host}/api/v4`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function projectPathFromContext(gitContext) {
|
|
41
|
+
const projectPath = String(gitContext?.projectPath || "").trim().replace(/^\/+|\/+$/g, "");
|
|
42
|
+
if (!projectPath) throw new Error("gitContext.projectPath is required");
|
|
43
|
+
return projectPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function encodeProject(projectPath) {
|
|
47
|
+
return encodeURIComponent(projectPath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function gitlabJson(url, options = {}) {
|
|
51
|
+
const res = await fetch(url, options);
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
let json = null;
|
|
54
|
+
try {
|
|
55
|
+
json = text ? JSON.parse(text) : null;
|
|
56
|
+
} catch {
|
|
57
|
+
json = null;
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const message = json?.message ? JSON.stringify(json.message) : text || `HTTP ${res.status}`;
|
|
61
|
+
throw new Error(`GitLab API failed: ${message}`);
|
|
62
|
+
}
|
|
63
|
+
return json;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function latestCommitSubject(repoPath) {
|
|
67
|
+
return gitOrThrow(["log", "-1", "--pretty=%s"], repoPath, "git log subject") || "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function recentCommitDescription(repoPath, targetBranch, sourceBranch) {
|
|
71
|
+
const ranges = [
|
|
72
|
+
[`origin/${targetBranch}..${sourceBranch}`],
|
|
73
|
+
[`${targetBranch}..${sourceBranch}`],
|
|
74
|
+
["-5"],
|
|
75
|
+
];
|
|
76
|
+
for (const range of ranges) {
|
|
77
|
+
const result = runGit(["log", "--pretty=format:- %h %s", ...range], repoPath);
|
|
78
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeLabels(labels) {
|
|
84
|
+
return String(labels || "")
|
|
85
|
+
.split(",")
|
|
86
|
+
.map((label) => label.trim())
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(",");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function createGitLabMergeRequest(payload = {}, env = process.env) {
|
|
92
|
+
const inputGitContext = normalizeGitContext(payload.gitContext);
|
|
93
|
+
const repoCandidate = String(payload.repoPath || "").trim() ||
|
|
94
|
+
inputGitContext?.worktreePath ||
|
|
95
|
+
inputGitContext?.repoPath ||
|
|
96
|
+
String(payload.workspaceCwd || "").trim();
|
|
97
|
+
if (!repoCandidate) throw new Error("repoPath, gitContext, or workspaceContext cwd is required");
|
|
98
|
+
const repoPath = resolveGitRepoRoot(repoCandidate);
|
|
99
|
+
const remote = String(payload.remote || inputGitContext?.remote || "origin").trim() || "origin";
|
|
100
|
+
const commit = currentGitCommit(repoPath);
|
|
101
|
+
const gitContext = inputGitContext?.projectPath
|
|
102
|
+
? { ...inputGitContext, commit: inputGitContext.commit || commit, remote }
|
|
103
|
+
: buildGitContext({ repoPath, branch: currentGitBranch(repoPath) || "DETACHED", commit, remote });
|
|
104
|
+
const sourceBranch = String(payload.sourceBranch || gitContext.branch || currentGitBranch(repoPath) || "").trim();
|
|
105
|
+
if (!sourceBranch || sourceBranch === "DETACHED") {
|
|
106
|
+
throw new Error("sourceBranch is required when repository is in detached HEAD");
|
|
107
|
+
}
|
|
108
|
+
const targetBranch = String(payload.targetBranch || "main").trim() || "main";
|
|
109
|
+
const title = String(payload.title || "").trim() || latestCommitSubject(repoPath) || `${sourceBranch} -> ${targetBranch}`;
|
|
110
|
+
const description = String(payload.description || "").trim() || recentCommitDescription(repoPath, targetBranch, sourceBranch);
|
|
111
|
+
const apiBase = apiBaseFromContext(gitContext, payload.gitlabApiBase);
|
|
112
|
+
const projectPath = projectPathFromContext(gitContext);
|
|
113
|
+
const { token, tokenEnv } = resolveToken(env, payload.tokenEnv);
|
|
114
|
+
const headers = {
|
|
115
|
+
"PRIVATE-TOKEN": token,
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (isTruthy(payload.push, true)) {
|
|
120
|
+
const push = runGit(["push", "-u", remote, sourceBranch], repoPath);
|
|
121
|
+
if (push.status !== 0) throw new Error(`git push failed: ${push.stderr || push.stdout}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const query = new URLSearchParams({
|
|
125
|
+
state: "opened",
|
|
126
|
+
source_branch: sourceBranch,
|
|
127
|
+
target_branch: targetBranch,
|
|
128
|
+
});
|
|
129
|
+
const projectId = encodeProject(projectPath);
|
|
130
|
+
const existing = await gitlabJson(`${apiBase}/projects/${projectId}/merge_requests?${query.toString()}`, { headers });
|
|
131
|
+
const openMr = Array.isArray(existing) ? existing[0] : null;
|
|
132
|
+
if (openMr?.web_url) {
|
|
133
|
+
return {
|
|
134
|
+
created: false,
|
|
135
|
+
mrUrl: openMr.web_url,
|
|
136
|
+
mrIid: openMr.iid,
|
|
137
|
+
projectId: openMr.project_id,
|
|
138
|
+
sourceBranch,
|
|
139
|
+
targetBranch,
|
|
140
|
+
title: openMr.title || title,
|
|
141
|
+
message: `reused existing MR: ${openMr.web_url}`,
|
|
142
|
+
tokenEnv,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const body = {
|
|
147
|
+
source_branch: sourceBranch,
|
|
148
|
+
target_branch: targetBranch,
|
|
149
|
+
title: isTruthy(payload.draft, false) && !/^draft:/i.test(title) ? `Draft: ${title}` : title,
|
|
150
|
+
description,
|
|
151
|
+
remove_source_branch: isTruthy(payload.removeSourceBranch, false),
|
|
152
|
+
squash: isTruthy(payload.squash, false),
|
|
153
|
+
};
|
|
154
|
+
const labels = normalizeLabels(payload.labels);
|
|
155
|
+
if (labels) body.labels = labels;
|
|
156
|
+
|
|
157
|
+
const created = await gitlabJson(`${apiBase}/projects/${projectId}/merge_requests`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers,
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
});
|
|
162
|
+
if (!created?.web_url) throw new Error("GitLab API did not return merge request URL");
|
|
163
|
+
return {
|
|
164
|
+
created: true,
|
|
165
|
+
mrUrl: created.web_url,
|
|
166
|
+
mrIid: created.iid,
|
|
167
|
+
projectId: created.project_id,
|
|
168
|
+
sourceBranch,
|
|
169
|
+
targetBranch,
|
|
170
|
+
title: created.title || body.title,
|
|
171
|
+
message: `created MR: ${created.web_url}`,
|
|
172
|
+
tokenEnv,
|
|
173
|
+
};
|
|
174
|
+
}
|
package/bin/lib/locales/en.json
CHANGED
|
@@ -281,6 +281,18 @@
|
|
|
281
281
|
"displayName": "Git Checkout",
|
|
282
282
|
"description": "Clone or update a Git repository and expose it as a workspace context"
|
|
283
283
|
},
|
|
284
|
+
"tool_git_worktree_load": {
|
|
285
|
+
"displayName": "Load Worktree",
|
|
286
|
+
"description": "Create or reuse a Git worktree and output workspaceContext and gitContext"
|
|
287
|
+
},
|
|
288
|
+
"tool_git_worktree_unload": {
|
|
289
|
+
"displayName": "Unload Worktree",
|
|
290
|
+
"description": "Remove a Git worktree and restore downstream workspace context"
|
|
291
|
+
},
|
|
292
|
+
"tool_gitlab_create_mr": {
|
|
293
|
+
"displayName": "Create GitLab MR",
|
|
294
|
+
"description": "Create or reuse a GitLab merge request for the current branch and output its URL"
|
|
295
|
+
},
|
|
284
296
|
"tool_nodejs": {
|
|
285
297
|
"displayName": "Node.js Script",
|
|
286
298
|
"description": "Execute Node.js script, success determined by exit code, stdout as result"
|
|
@@ -313,6 +325,14 @@
|
|
|
313
325
|
"displayName": "ASCII Display",
|
|
314
326
|
"description": "Render ASCII diagram text on the Workspace canvas and pass the text downstream"
|
|
315
327
|
},
|
|
328
|
+
"display_html": {
|
|
329
|
+
"displayName": "HTML Display",
|
|
330
|
+
"description": "Render HTML in a sandboxed iframe on the Workspace canvas and pass the source downstream"
|
|
331
|
+
},
|
|
332
|
+
"display_image": {
|
|
333
|
+
"displayName": "Image Display",
|
|
334
|
+
"description": "Preview an image URL, data URL, or image path on the Workspace canvas and pass the source downstream"
|
|
335
|
+
},
|
|
316
336
|
"provide_str": {
|
|
317
337
|
"displayName": "Text",
|
|
318
338
|
"description": "Provide a text value directly, value will be passed to downstream as-is"
|
|
@@ -320,6 +340,10 @@
|
|
|
320
340
|
"provide_file": {
|
|
321
341
|
"displayName": "File",
|
|
322
342
|
"description": "Provide file path or content directly, value will be passed to downstream as-is"
|
|
343
|
+
},
|
|
344
|
+
"provide_bool": {
|
|
345
|
+
"displayName": "Boolean",
|
|
346
|
+
"description": "Provide a true/false boolean value directly to downstream bool pins"
|
|
323
347
|
}
|
|
324
348
|
},
|
|
325
349
|
"pipeline": {
|