Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94fa964b84 | |||
| 2c642b1a53 | |||
| c15a5554c0 | |||
| e38852a761 | |||
| fd8f1f2491 | |||
| 98b6153070 |
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+5
-2
@@ -7,11 +7,14 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run --filter '*' build",
|
||||
"check": "biome check .",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14"
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
@@ -23,7 +23,7 @@ function isProcessAlive(pid: number): boolean {
|
||||
|
||||
async function waitForReadyLine(
|
||||
childStdout: NodeJS.ReadableStream,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
child: ChildProcess,
|
||||
): Promise<Result<number, string>> {
|
||||
return await new Promise((resolve) => {
|
||||
let buf = "";
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [{ "path": "../workflow" }],
|
||||
"include": ["src/**/*.ts"]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-util-agent" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,13 +19,13 @@ describe("createLlmAdapter", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("posts system + user (start.content) and returns assistant text", async () => {
|
||||
globalThis.fetch = () =>
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
@@ -37,13 +37,13 @@ describe("createLlmAdapter", () => {
|
||||
});
|
||||
|
||||
test("throws on non-ok fetch response", async () => {
|
||||
globalThis.fetch = () =>
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response("Internal Server Error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
@@ -53,7 +53,7 @@ describe("createLlmAdapter", () => {
|
||||
});
|
||||
|
||||
test("throws on fetch network failure", async () => {
|
||||
globalThis.fetch = () => Promise.reject(new Error("ECONNREFUSED"));
|
||||
globalThis.fetch = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
@@ -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";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -50,7 +50,10 @@ describe("createReviewerRole", () => {
|
||||
});
|
||||
|
||||
test("runs reviewer extract", async () => {
|
||||
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: true })));
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
toolCallResponse(JSON.stringify({ approved: true })),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const agent: AgentFn = async (_ctx, prompt) => {
|
||||
expect(prompt).toContain("git diff");
|
||||
@@ -68,7 +71,10 @@ describe("createReviewerRole", () => {
|
||||
});
|
||||
|
||||
test("includes uncaged-workflow thread hint when threadId set", async () => {
|
||||
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: false })));
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
toolCallResponse(JSON.stringify({ approved: false })),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
let seen = "";
|
||||
const agent: AgentFn = async (_ctx, prompt) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe("createSolveIssueRoles", () => {
|
||||
expect(typeof roles.committer).toBe("function");
|
||||
|
||||
const ctx = makeCtx(10, []);
|
||||
const plannerOut = await roles.planner(ctx);
|
||||
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,
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+6
-3
@@ -55,7 +55,8 @@ describe("createRole", () => {
|
||||
});
|
||||
|
||||
test("runs AgentFn then structured extract", async () => {
|
||||
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })));
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const agent: AgentFn = async (_ctx, prompt) => prompt;
|
||||
@@ -73,7 +74,8 @@ describe("createRole", () => {
|
||||
});
|
||||
|
||||
test("passes ThreadContext to AgentFn", async () => {
|
||||
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })));
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch;
|
||||
|
||||
const seen: ThreadContext[] = [];
|
||||
const agent: AgentFn = async (ctx, _prompt) => {
|
||||
@@ -94,7 +96,8 @@ describe("createRole", () => {
|
||||
});
|
||||
|
||||
test("resolves dynamic systemPrompt functions before AgentFn", async () => {
|
||||
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })));
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })))) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const agent: AgentFn = async (_ctx, prompt) => prompt;
|
||||
@@ -14,10 +14,10 @@ describe("extractMetaOrThrow", () => {
|
||||
|
||||
test("dryRun returns dryRunMeta without calling fetch", async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = () => {
|
||||
globalThis.fetch = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const out = await extractMetaOrThrow("r", "raw", schema, {
|
||||
@@ -33,7 +33,7 @@ describe("extractMetaOrThrow", () => {
|
||||
});
|
||||
|
||||
test("throws when extraction fails after retry", async () => {
|
||||
globalThis.fetch = () =>
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -49,7 +49,7 @@ describe("extractMetaOrThrow", () => {
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("extractMetaOrThrow", () => {
|
||||
});
|
||||
|
||||
test("returns validated meta on successful tool call", async () => {
|
||||
globalThis.fetch = () =>
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -82,7 +82,7 @@ describe("extractMetaOrThrow", () => {
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({
|
||||
branch: z.string(),
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("llmExtract", () => {
|
||||
let capturedUrl: string | null = null;
|
||||
let capturedInit: RequestInit | null = null;
|
||||
|
||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
|
||||
capturedUrl = typeof input === "string" ? input : input.toString();
|
||||
capturedInit = init ?? null;
|
||||
return Promise.resolve(
|
||||
@@ -44,7 +44,7 @@ describe("llmExtract", () => {
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await llmExtract({
|
||||
text: "some plan",
|
||||
@@ -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 } };
|
||||
};
|
||||
@@ -83,7 +83,7 @@ describe("llmExtract", () => {
|
||||
test("returns schema_validation_failed when arguments do not match the schema", async () => {
|
||||
const schema = z.object({ n: z.number() });
|
||||
|
||||
globalThis.fetch = () =>
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -99,7 +99,7 @@ describe("llmExtract", () => {
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const result = await llmExtract({
|
||||
text: "x",
|
||||
@@ -120,10 +120,10 @@ describe("llmExtract", () => {
|
||||
|
||||
test("dryRun skips fetch and returns dryRunMeta", async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = () => {
|
||||
globalThis.fetch = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const result = await llmExtract({
|
||||
|
||||
+2
-1
@@ -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,4 +1,5 @@
|
||||
export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js";
|
||||
export { type CreateRoleArgs, createRole } from "./create-role.js";
|
||||
export {
|
||||
decorateRole,
|
||||
type OnFailOptions,
|
||||
|
||||
@@ -10,6 +10,18 @@ import type {
|
||||
Program,
|
||||
VariableDeclaration,
|
||||
} from "acorn";
|
||||
|
||||
/** Acorn Node with index-access for property traversal. */
|
||||
type AcornNode = Node & { [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* Narrow an Acorn Node to a specific AST subtype after a `.type` guard.
|
||||
* Avoids double-cast (`as unknown as T`) by going through AcornNode.
|
||||
*/
|
||||
function narrowNode<T extends Node>(node: Node): T {
|
||||
return node as unknown as T;
|
||||
}
|
||||
|
||||
import * as acorn from "acorn";
|
||||
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
@@ -55,7 +67,7 @@ function pushNestedAstNodes(value: unknown, out: Node[]): void {
|
||||
function collectChildNodes(node: Node): Node[] {
|
||||
const children: Node[] = [];
|
||||
for (const key of Object.keys(node)) {
|
||||
const val = (node as Record<string, unknown>)[key];
|
||||
const val = (node as AcornNode)[key];
|
||||
pushNestedAstNodes(val, children);
|
||||
}
|
||||
return children;
|
||||
@@ -273,10 +285,10 @@ function descriptorExportExists(program: Program): boolean {
|
||||
}
|
||||
|
||||
function stringLiteralModuleSpecifier(src: Node): string | null {
|
||||
if (src.type !== "Literal" || typeof src.value !== "string") {
|
||||
if (src.type !== "Literal" || typeof (src as AcornNode).value !== "string") {
|
||||
return null;
|
||||
}
|
||||
return src.value;
|
||||
return (src as AcornNode).value as string;
|
||||
}
|
||||
|
||||
function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||
@@ -337,16 +349,16 @@ function bundleConstraintViolationForNode(node: Node): string | null {
|
||||
return "dynamic import() is not allowed in workflow bundles";
|
||||
}
|
||||
if (node.type === "ImportDeclaration") {
|
||||
return validateImportDeclaration(node);
|
||||
return validateImportDeclaration(narrowNode<ImportDeclaration>(node));
|
||||
}
|
||||
if (node.type === "ExportNamedDeclaration") {
|
||||
return validateExportNamedDeclaration(node);
|
||||
return validateExportNamedDeclaration(narrowNode<ExportNamedDeclaration>(node));
|
||||
}
|
||||
if (node.type === "ExportAllDeclaration") {
|
||||
return validateExportAllDeclaration(node);
|
||||
return validateExportAllDeclaration(narrowNode<ExportAllDeclaration>(node));
|
||||
}
|
||||
if (node.type === "CallExpression") {
|
||||
return validateRequireCall(node);
|
||||
return validateRequireCall(narrowNode<CallExpression>(node));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function createRoleModerator<M extends RoleMeta>(
|
||||
return { returnCode: 1, summary: `unknown role: ${next}` };
|
||||
}
|
||||
|
||||
const result = await roleFn(ctx);
|
||||
const result = await roleFn(ctx as unknown as ThreadContext);
|
||||
const ts = Date.now();
|
||||
const step = {
|
||||
role: next,
|
||||
|
||||
@@ -423,7 +423,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((socket) => {
|
||||
const server = createServer((socket: Socket) => {
|
||||
void (async () => {
|
||||
const line = await readLineFromSocket(socket);
|
||||
if (line === null) {
|
||||
@@ -439,7 +439,7 @@ async function main(): Promise<void> {
|
||||
})();
|
||||
});
|
||||
|
||||
server.on("error", (errObj) => {
|
||||
server.on("error", (errObj: Error) => {
|
||||
bootLog("W8YK4NPX", `worker server error: ${errObj.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "xxhashjs.d.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -13,7 +13,8 @@
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [
|
||||
{ "path": "packages/workflow" },
|
||||
|
||||
Reference in New Issue
Block a user