Compare commits

..

6 Commits

Author SHA1 Message Date
xingyue 94fa964b84 fix(workflow): add typecheck script and fix remaining type errors
- Add typecheck script (bunx tsc --build) to package.json
- Update check script to run typecheck before biome
- Fix mock fetch casts in test files (bun-types preconnect)
- Fix RequestInfo → Request | string | URL in llm-extract test
- Fix ThreadContext generic cast in solve-issue-template test
- Fix git-exec.ts missing return and module resolution
- Remove @types/node from workflow-role-committer
- Add exports field to workflow-util-agent/package.json
2026-05-06 18:10:25 +08:00
xiaoju 2c642b1a53 refactor: committer as pure agent role with discriminated union meta (#17)
- Remove hardcoded git commands (git-exec.ts deleted)
- CommitterMeta is now a discriminated union: committed | failed
- Agent handles git operations, committer extracts structured result
- Moderator can route on meta.status for retry logic

Closes #17
2026-05-06 10:06:27 +00:00
xiaoju c15a5554c0 refactor: replace gitExec with spawnCli from workflow-util-agent
Remove hand-rolled execFile + promisify, delegate to spawnCli.
Same throw-on-error interface, better error messages.
2026-05-06 09:51:06 +00:00
xiaoju e38852a761 Merge pull request 'fix(workflow): resolve type errors across all packages' (#16) from fix/type-errors-and-tsbuildinfo into main 2026-05-06 09:44:49 +00:00
xiaoju fd8f1f2491 refactor: move createRole to workflow-util-role
workflow-role-llm now only contains createLlmAdapter (OpenAI chat
completions → AgentFn). All role infrastructure lives in util-role.
2026-05-06 09:42:49 +00:00
xingyue 98b6153070 fix(workflow): resolve type errors across all packages and remove tsbuildinfo from tracking
- Add bun-types and @types/xxhashjs to root devDependencies
- Add types: ['bun-types'] to root and package tsconfigs
- Fix AST Node type narrowing in bundle-validator.ts with AcornNode type and narrowNode helper
- Fix generic ThreadContext variance in create-role-moderator.ts
- Add explicit parameter types in worker.ts
- Fix ChildProcess type in worker-spawn.ts to match spawn() stdio config
- Remove all tsconfig.tsbuildinfo from git tracking
- Add tsconfig.tsbuildinfo to .gitignore
2026-05-06 17:41:11 +08:00
14 changed files with 174 additions and 207 deletions
@@ -1,40 +1,10 @@
import { describe, expect, spyOn, test } from "bun:test";
import { execFile } from "node:child_process";
import { appendFile, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as utilRole from "@uncaged/workflow-util-role";
import { createCommitterRole } from "../src/committer.js";
import { gitExec } from "../src/git-exec.js";
const execFileAsync = promisify(execFile);
async function git(repo: string, args: string[]): Promise<void> {
await gitExec(repo, args);
}
async function setupRepoWithRemote(): Promise<{ repo: string }> {
const base = await mkdtemp(join(tmpdir(), "wf-committer-"));
const bare = join(base, "origin.git");
const repo = join(base, "work");
await mkdir(repo, { recursive: true });
await mkdir(bare, { recursive: true });
await execFileAsync("git", ["init"], { cwd: repo, encoding: "utf8" });
await git(repo, ["config", "user.email", "t@t"]);
await git(repo, ["config", "user.name", "t"]);
await writeFile(join(repo, "README.md"), "# hi\n", "utf8");
await git(repo, ["add", "README.md"]);
await git(repo, ["commit", "-m", "init"]);
await execFileAsync("git", ["init", "--bare"], { cwd: bare, encoding: "utf8" });
await git(repo, ["remote", "add", "origin", bare]);
await git(repo, ["push", "-u", "origin", "HEAD"]);
return { repo };
}
function makeCtx(): ThreadContext {
return {
@@ -50,21 +20,13 @@ function makeCtx(): ThreadContext {
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
describe("createCommitterRole", () => {
test("returns committed false when working tree clean", async () => {
const { repo } = await setupRepoWithRemote();
const agent: AgentFn = async () => {
throw new Error("agent should not run");
};
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(false);
});
const dryRunMeta = {
status: "committed" as const,
branch: "dry-run/placeholder",
commitSha: "0000000",
};
describe("createCommitterRole", () => {
test("dry-run skips pipeline", async () => {
const agent: AgentFn = async () => {
throw new Error("agent should not run");
@@ -72,39 +34,101 @@ describe("createCommitterRole", () => {
const role = createCommitterRole(agent, {
provider,
dryRun: true,
dryRunMeta: { branch: "dry-run", message: "chore: dry run" },
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.content).toBe("[dry-run] committer skipped");
expect(out.meta).toEqual({ committed: true });
expect(out.meta).toEqual(dryRunMeta);
});
test("commits and pushes when changes exist", async () => {
const { repo } = await setupRepoWithRemote();
await appendFile(join(repo, "README.md"), "\nmore\n", "utf8");
test("returns committed meta when extraction succeeds", async () => {
const committed = {
status: "committed" as const,
branch: "feat/widget",
commitSha: "deadbeef".repeat(5).slice(0, 40),
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue({
branch: "feat/test-commit",
message: "feat: add more",
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(committed);
const agent: AgentFn = async (_ctx, prompt) =>
`Created branch ${committed.branch}, pushed. SHA ${committed.commitSha}.\n${prompt.slice(0, 80)}`;
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const agent: AgentFn = async () => "plan text";
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(true);
expect(out.meta).toEqual(committed);
expect(spy).toHaveBeenCalled();
const branches = await gitExec(repo, ["branch", "--list", "feat/test-commit"]);
expect(branches).toContain("feat/test-commit");
const remoteRefs = await gitExec(repo, ["ls-remote", "--heads", "origin", "feat/test-commit"]);
expect(remoteRefs.trim().length).toBeGreaterThan(0);
spy.mockRestore();
});
test("returns failed meta when extraction reports failure", async () => {
const failed = {
status: "failed" as const,
error: "working tree clean; nothing to commit",
logRef: null as string | null,
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "git status shows no changes; skipping branch and commit.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns failed meta with logRef when extraction includes it", async () => {
const failed = {
status: "failed" as const,
error: "push rejected",
logRef: "LOGREF01",
};
spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "Remote rejected non-fast-forward.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
});
test("onFail wraps extraction errors", async () => {
spyOn(utilRole, "extractMetaOrThrow").mockRejectedValue(
new Error("structured extraction failed"),
);
const agent: AgentFn = async () => "opaque agent output";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual({
status: "failed",
error: "committer role threw before structured result",
logRef: null,
});
expect(out.content).toContain("committer failed:");
expect(out.content).toContain("structured extraction failed");
});
});
@@ -12,8 +12,5 @@
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^25.6.0"
}
}
@@ -1,29 +1,28 @@
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import {
createRole,
decorateRole,
extractMetaOrThrow,
type LlmProvider,
onFail,
withDryRun,
} from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import { gitExec } from "./git-exec.js";
export const committerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("committed"),
branch: z.string(),
commitSha: z.string(),
}),
z.object({
status: z.literal("failed"),
error: z.string(),
logRef: z.string().nullable(),
}),
]);
export const committerMetaSchema = z.object({
committed: z
.boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
const committerPlanSchema = z.object({
branch: z.string().describe("Feature branch name, e.g. feat/slug or fix/slug"),
message: z.string().describe("Single-line conventional commit subject"),
});
export type CommitterPlanMeta = z.infer<typeof committerPlanSchema>;
export type CommitterGitConfig = {
cwd: string;
remote: string;
@@ -37,6 +36,12 @@ export const DEFAULT_COMMITTER_GIT_CONFIG: CommitterGitConfig = {
threadId: null,
};
const DRY_RUN_COMMITTED_META: CommitterMeta = {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
@@ -50,37 +55,18 @@ function summarizeThreadContext(ctx: ThreadContext): string {
return lines.join("\n");
}
function sanitizeBranch(branch: string): string {
const t = branch.trim();
if (
t === "" ||
t.includes("..") ||
t.includes(" ") ||
t.startsWith("-") ||
t.includes("\n") ||
t.includes("\t")
) {
throw new Error(`invalid branch name: ${branch}`);
}
return t;
}
function sanitizeCommitMessage(message: string): string {
const line = message.trim().split(/\r?\n/)[0] ?? "";
if (line === "") {
throw new Error("commit message is empty");
}
return line;
}
function committerPlanPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
function committerSystemPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
const threadLine =
gitConfig.threadId !== null
? `Optional CLI context: run \`uncaged-workflow thread ${gitConfig.threadId}\` if available.\n`
: "";
return `You plan a git branch and a single-line conventional commit message for the following workflow thread.
return `You are the **git committer** for this workflow. Prior roles planned, implemented, and reviewed the change; your job is to perform git operations in the repository and report the outcome.
## Repository context
- Working directory (run git commands here): \`${gitConfig.cwd}\`
- Remote name for push: \`${gitConfig.remote}\`
${threadLine}
## Thread context
@@ -88,66 +74,44 @@ ${summarizeThreadContext(ctx)}
## Your task
Infer a good branch name (\`feat/<slug>\` or \`fix/<slug>\`) and a conventional commit **subject** (one line, no body).
1. Inspect the working tree (e.g. \`git status\`). If there is nothing to commit, stop and explain why in your reply.
2. Create a new branch using **conventional** naming (\`feat/<slug>\`, \`fix/<slug>\`, or \`chore/<slug>\` as appropriate).
3. Stage all intended changes, commit with a **single-line conventional commit subject**, and push the branch to \`${gitConfig.remote}\` (e.g. \`git push -u ${gitConfig.remote} <branch>\`).
4. In your reply, state clearly whether the push succeeded, the **exact branch name** used, and the **full commit SHA** from \`git rev-parse HEAD\` (or explain the failure).
Reply with enough detail that a maintainer understands the change; structured extraction will read \`branch\` and \`message\` from your answer.`;
}
async function runCommitterPipeline(
ctx: ThreadContext,
agent: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
gitConfig: CommitterGitConfig,
): Promise<RoleResult<CommitterMeta>> {
const cwd = gitConfig.cwd;
const porcelain = await gitExec(cwd, ["status", "--porcelain"]);
if (porcelain.trim() === "") {
return {
content: "Working tree clean; nothing to commit.",
meta: { committed: false },
};
}
const prompt = committerPlanPrompt(ctx, gitConfig);
const raw = await agent(ctx, prompt);
const plan = await extractMetaOrThrow("committer-plan", raw, committerPlanSchema, {
provider: extract.provider,
dryRun: resolveExtractDryRun(extract.dryRun),
dryRunMeta: extract.dryRunMeta,
});
const branch = sanitizeBranch(plan.branch);
const message = sanitizeCommitMessage(plan.message);
await gitExec(cwd, ["checkout", "-b", branch]);
await gitExec(cwd, ["add", "-A"]);
await gitExec(cwd, ["commit", "-m", message]);
await gitExec(cwd, ["push", "-u", gitConfig.remote, branch]);
return {
content: raw,
meta: { committed: true },
};
Structured extraction will read \`status\`, branch, commit SHA, or error details from your answer.`;
}
/**
* Git committer role: LLM proposes branch + message; this package runs git via `child_process`.
* Decorators match nerve semantics: dry-run skips work with `committed: true`; failures yield `committed: false`.
* Git committer role: the agent runs git (branch, commit, push); structured extraction yields {@link CommitterMeta}.
* Dry-run skips the agent and returns a stable committed placeholder; unexpected throws yield \`status: "failed"\`.
*/
export function createCommitterRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterMeta },
gitConfig: CommitterGitConfig = DEFAULT_COMMITTER_GIT_CONFIG,
): Role<CommitterMeta> {
const inner: Role<CommitterMeta> = async (ctx) =>
runCommitterPipeline(ctx, adapter, extract, gitConfig);
const inner: Role<CommitterMeta> = createRole({
name: "committer",
schema: committerMetaSchema,
systemPrompt: async (ctx) => committerSystemPrompt(ctx, gitConfig),
agent: adapter,
extract,
});
return decorateRole(inner, [
withDryRun<CommitterMeta>({
label: "committer",
meta: { committed: true },
meta: DRY_RUN_COMMITTED_META,
dryRun: resolveExtractDryRun(extract.dryRun),
}),
onFail<CommitterMeta>({ label: "committer", meta: { committed: false } }),
onFail<CommitterMeta>({
label: "committer",
meta: {
status: "failed",
error: "committer role threw before structured result",
logRef: null,
},
}),
]);
}
@@ -1,26 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** Runs `git` with args in `cwd`; throws if git exits non-zero. */
export async function gitExec(cwd: string, args: readonly string[]): Promise<string> {
try {
const r = await execFileAsync("git", [...args], {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return r.stdout;
} catch (e) {
const stderr =
typeof e === "object" &&
e !== null &&
"stderr" in e &&
typeof (e as { stderr: unknown }).stderr === "string"
? (e as { stderr: string }).stderr
: "";
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`git ${args.join(" ")} failed: ${msg}${stderr ? ` (${stderr.trim()})` : ""}`);
}
}
@@ -1,9 +1,7 @@
export {
type CommitterGitConfig,
type CommitterMeta,
type CommitterPlanMeta,
committerMetaSchema,
createCommitterRole,
DEFAULT_COMMITTER_GIT_CONFIG,
} from "./committer.js";
export { gitExec } from "./git-exec.js";
@@ -7,5 +7,9 @@
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-util-role" }
]
}
+2 -1
View File
@@ -1,5 +1,7 @@
export {
buildDescriptorFromRoles,
type CreateRoleArgs,
createRole,
decorateRole,
extractMetaOrThrow,
type LlmError,
@@ -18,4 +20,3 @@ export {
withDryRun,
} from "@uncaged/workflow-util-role";
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test";
import type { RoleMeta } from "@uncaged/workflow";
import {
END,
type RoleStep,
@@ -7,6 +6,7 @@ import {
type ThreadContext,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { solveIssueModerator } from "../src/moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
@@ -61,7 +61,7 @@ function committerStep(): RoleStep<SolveIssueMeta> {
return {
role: "committer",
content: "commit",
meta: { committed: true },
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
timestamp: 4,
};
}
@@ -114,8 +114,8 @@ describe("createSolveIssueRoles", () => {
expect(typeof roles.reviewer).toBe("function");
expect(typeof roles.committer).toBe("function");
const ctx = makeCtx(10, []) as unknown as ThreadContext<RoleMeta>;
const plannerOut = await roles.planner(ctx);
const ctx = makeCtx(10, []);
const plannerOut = await roles.planner(ctx as unknown as ThreadContext);
expect(plannerOut.meta.plan).toBe("");
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
});
@@ -1,9 +1,5 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import {
type CommitterMeta,
type CommitterPlanMeta,
createCommitterRole,
} from "@uncaged/workflow-role-committer";
import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer";
import { createRole } from "@uncaged/workflow-role-llm";
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
import type { LlmProvider } from "@uncaged/workflow-util-role";
@@ -53,9 +49,10 @@ const REVIEWER_DRY_RUN_META: ReviewerMeta = {
approved: true,
};
const COMMITTER_PLAN_DRY_RUN_META: CommitterPlanMeta = {
branch: "dry-run",
message: "chore: dry run",
const COMMITTER_DRY_RUN_META: CommitterMeta = {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
export type SolveIssueMeta = {
@@ -142,7 +139,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: COMMITTER_PLAN_DRY_RUN_META,
dryRunMeta: COMMITTER_DRY_RUN_META,
},
committerGit,
);
@@ -4,6 +4,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
@@ -14,12 +14,12 @@ describe("llmExtract", () => {
})
.describe("Extract sense metadata from plan");
let capturedUrl = "";
let capturedInit: RequestInit = {};
let capturedUrl: string | null = null;
let capturedInit: RequestInit | null = null;
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
capturedUrl = typeof input === "string" ? input : input.toString();
capturedInit = init ?? {};
capturedInit = init ?? null;
return Promise.resolve(
new Response(
JSON.stringify({
@@ -66,13 +66,13 @@ describe("llmExtract", () => {
}
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
expect(capturedUrl).toBe("https://example.com/v1/chat/completions");
expect(capturedInit.method).toBe("POST");
expect(capturedInit.headers).toMatchObject({
expect(capturedUrl!).toBe("https://example.com/v1/chat/completions");
expect(capturedInit!.method).toBe("POST");
expect(capturedInit!.headers).toMatchObject({
Authorization: "Bearer k",
"Content-Type": "application/json",
});
const body = JSON.parse(capturedInit.body as string) as {
const body = JSON.parse(capturedInit!.body as string) as {
model: string;
tool_choice: { function: { name: string } };
};
@@ -1,6 +1,7 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { extractMetaOrThrow, type LlmProvider } from "@uncaged/workflow-util-role";
import type * as z from "zod/v4";
import { extractMetaOrThrow } from "./extract-meta.js";
import type { LlmProvider } from "./types.js";
export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string;
+1
View File
@@ -1,4 +1,5 @@
export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
export {
decorateRole,
type OnFailOptions,