@agentplate/cli 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/agents/coordinator.md +43 -13
- package/agents/lead.md +8 -1
- package/package.json +5 -5
- package/src/agents/capacity.test.ts +55 -0
- package/src/agents/capacity.ts +50 -0
- package/src/agents/drive.test.ts +155 -0
- package/src/agents/drive.ts +200 -0
- package/src/agents/system-prompt.ts +2 -1
- package/src/commands/sling.test.ts +84 -0
- package/src/commands/sling.ts +73 -117
- package/src/commands/spec.test.ts +142 -0
- package/src/commands/spec.ts +192 -0
- package/src/commands/turn.test.ts +101 -0
- package/src/commands/turn.ts +113 -0
- package/src/config.test.ts +18 -0
- package/src/config.ts +6 -1
- package/src/errors.ts +11 -0
- package/src/index.ts +4 -0
- package/src/insights/quality-gates.test.ts +43 -0
- package/src/insights/quality-gates.ts +30 -31
- package/src/merge/auto.test.ts +157 -0
- package/src/merge/auto.ts +118 -0
- package/src/paths.ts +2 -1
- package/src/runtimes/resolve.test.ts +49 -0
- package/src/runtimes/resolve.ts +11 -7
- package/src/sessions/store.test.ts +13 -0
- package/src/sessions/store.ts +20 -0
- package/src/types.ts +16 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.test.ts +45 -0
- package/src/wizard/setup.ts +119 -6
- package/ui/dist/assets/index-DAq3_wei.css +1 -0
- package/ui/dist/assets/index-DjRGeS6V.js +4227 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-C7rXIMER.css +0 -1
- package/ui/dist/assets/index-W4kbr4by.js +0 -4526
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate turn` command tests. Real initialized temp project + a real (mock)
|
|
3
|
+
* runtime turn. Drives the exported command's action via parseAsync.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
AGENTPLATE_DIR,
|
|
12
|
+
CONFIG_FILE,
|
|
13
|
+
DEFAULT_CONFIG,
|
|
14
|
+
serializeConfig,
|
|
15
|
+
setProjectRootOverride,
|
|
16
|
+
} from "../config.ts";
|
|
17
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
19
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
20
|
+
import type { AgentSession, SessionState } from "../types.ts";
|
|
21
|
+
import { createTurnCommand } from "./turn.ts";
|
|
22
|
+
|
|
23
|
+
let root: string;
|
|
24
|
+
let worktree: string;
|
|
25
|
+
|
|
26
|
+
function seedSession(over: Partial<AgentSession>): void {
|
|
27
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
try {
|
|
30
|
+
store.upsertSession({
|
|
31
|
+
id: `session-${crypto.randomUUID()}`,
|
|
32
|
+
agentName: "builder-1",
|
|
33
|
+
capability: "builder",
|
|
34
|
+
taskId: "task-1",
|
|
35
|
+
runId: "run-1",
|
|
36
|
+
worktreePath: worktree,
|
|
37
|
+
branchName: "agentplate/builder-1",
|
|
38
|
+
state: "idle",
|
|
39
|
+
parentAgent: "lead-1",
|
|
40
|
+
depth: 1,
|
|
41
|
+
pid: null,
|
|
42
|
+
runtimeSessionId: "sess-prior",
|
|
43
|
+
startedAt: now,
|
|
44
|
+
lastActivity: now,
|
|
45
|
+
...over,
|
|
46
|
+
});
|
|
47
|
+
} finally {
|
|
48
|
+
store.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sessionState(agent: string): SessionState | undefined {
|
|
53
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
54
|
+
try {
|
|
55
|
+
return store.getSessionByAgent(agent)?.state;
|
|
56
|
+
} finally {
|
|
57
|
+
store.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runTurnCmd(agent: string): Promise<void> {
|
|
62
|
+
await createTurnCommand().parseAsync([agent], { from: "user" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-turn-cmd-"));
|
|
67
|
+
worktree = mkdtempSync(join(tmpdir(), "agentplate-turn-wt-"));
|
|
68
|
+
mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
|
|
69
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
70
|
+
config.runtime.default = "mock";
|
|
71
|
+
writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
|
|
72
|
+
setProjectRootOverride(root);
|
|
73
|
+
process.env.AGENTPLATE_MOCK_CMD = "true";
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
setProjectRootOverride(null);
|
|
78
|
+
rmSync(root, { recursive: true, force: true });
|
|
79
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
80
|
+
process.env.AGENTPLATE_MOCK_CMD = undefined;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("agentplate turn — refusals", () => {
|
|
84
|
+
test("throws NotFoundError for an unknown agent", async () => {
|
|
85
|
+
await expect(runTurnCmd("ghost")).rejects.toBeInstanceOf(NotFoundError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("refuses a terminal (completed) agent", async () => {
|
|
89
|
+
seedSession({ state: "completed" });
|
|
90
|
+
await expect(runTurnCmd("builder-1")).rejects.toBeInstanceOf(ValidationError);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("agentplate turn — runs the next turn", () => {
|
|
95
|
+
test("an idle agent takes another turn and transitions", async () => {
|
|
96
|
+
seedSession({ state: "idle" });
|
|
97
|
+
await runTurnCmd("builder-1");
|
|
98
|
+
// No terminal mail from the mock no-op → stays idle (ran without throwing).
|
|
99
|
+
expect(sessionState("builder-1")).toBe("idle");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate turn <agent>` — run the NEXT headless turn for an existing agent.
|
|
3
|
+
*
|
|
4
|
+
* Where `sling` opens a fresh runtime session (turn 1), `turn` **resumes** it: it
|
|
5
|
+
* passes the session's captured `runtimeSessionId` to the runtime's `--resume`, so
|
|
6
|
+
* follow-up turns keep the warm context and skip the cold-start cost. The agent's
|
|
7
|
+
* unread mail is injected as the turn's prompt; the shared {@link driveTurn} core
|
|
8
|
+
* handles the state transition, skills loop, and auto-merge identically to turn 1.
|
|
9
|
+
*
|
|
10
|
+
* This is the multi-turn primitive: a coordinator/lead (or a future watcher) calls
|
|
11
|
+
* it when new mail arrives for an `idle` agent. Spawn-per-turn is preserved — each
|
|
12
|
+
* call is one fresh, resumed runtime subprocess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { driveTurn } from "../agents/drive.ts";
|
|
18
|
+
import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
|
|
19
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
20
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
21
|
+
import { createEventStore } from "../events/store.ts";
|
|
22
|
+
import { jsonOutput } from "../json.ts";
|
|
23
|
+
import { brand, muted, printInfo, printSuccess } from "../logging/color.ts";
|
|
24
|
+
import { createMailClient } from "../mail/client.ts";
|
|
25
|
+
import { eventsDbPath, manifestFilePath, sessionsDbPath } from "../paths.ts";
|
|
26
|
+
import { getRuntime } from "../runtimes/registry.ts";
|
|
27
|
+
import { resolveModel } from "../runtimes/resolve.ts";
|
|
28
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
29
|
+
import type { SessionState } from "../types.ts";
|
|
30
|
+
|
|
31
|
+
interface TurnOptions {
|
|
32
|
+
json?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** States a turn can be driven from: paused/awaiting mail (not terminal, not mid-turn). */
|
|
36
|
+
const DRIVABLE_STATES: ReadonlySet<SessionState> = new Set<SessionState>(["idle", "booting"]);
|
|
37
|
+
|
|
38
|
+
export function createTurnCommand(): Command {
|
|
39
|
+
return new Command("turn")
|
|
40
|
+
.description("Run the next (resumed) turn for an existing agent")
|
|
41
|
+
.argument("<agent>", "agent name")
|
|
42
|
+
.option("--json", "output JSON")
|
|
43
|
+
.action(async (agent: string, opts: TurnOptions, command: Command) => {
|
|
44
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
45
|
+
const root = findProjectRoot();
|
|
46
|
+
if (!isInitialized(root)) {
|
|
47
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
48
|
+
}
|
|
49
|
+
const config = loadConfig(root);
|
|
50
|
+
|
|
51
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
52
|
+
const mail = createMailClient(root);
|
|
53
|
+
const events = createEventStore(eventsDbPath(root));
|
|
54
|
+
try {
|
|
55
|
+
const session = store.getSessionByAgent(agent);
|
|
56
|
+
if (!session) throw new NotFoundError(`No agent named "${agent}".`);
|
|
57
|
+
if (!DRIVABLE_STATES.has(session.state)) {
|
|
58
|
+
throw new ValidationError(
|
|
59
|
+
`Agent "${agent}" is ${session.state}; only an idle agent can take another turn.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const manifestPath = manifestFilePath(root);
|
|
64
|
+
const manifest = existsSync(manifestPath)
|
|
65
|
+
? loadManifest(manifestPath)
|
|
66
|
+
: buildDefaultManifest();
|
|
67
|
+
const def = getDefinition(manifest, session.capability);
|
|
68
|
+
const runtime = getRuntime(config.runtime.default, config.runtime.default);
|
|
69
|
+
const model = resolveModel(config, root, def.model, session.capability);
|
|
70
|
+
|
|
71
|
+
// The next turn's user text is the agent's unread mail (e.g. a child's
|
|
72
|
+
// reply or operator direction); fall back to a continue nudge.
|
|
73
|
+
const injected = mail.checkInject(agent);
|
|
74
|
+
const prompt =
|
|
75
|
+
injected.trim().length > 0
|
|
76
|
+
? injected
|
|
77
|
+
: "Continue your task. If it is complete, send your terminal mail.";
|
|
78
|
+
|
|
79
|
+
const { finalState, exitCode } = await driveTurn({
|
|
80
|
+
root,
|
|
81
|
+
config,
|
|
82
|
+
runtime,
|
|
83
|
+
store,
|
|
84
|
+
events,
|
|
85
|
+
mail,
|
|
86
|
+
session,
|
|
87
|
+
model,
|
|
88
|
+
prompt,
|
|
89
|
+
// Warm start: resume the runtime session captured on a prior turn.
|
|
90
|
+
resumeSessionId: session.runtimeSessionId ?? undefined,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (useJson) {
|
|
94
|
+
jsonOutput({
|
|
95
|
+
agent,
|
|
96
|
+
capability: session.capability,
|
|
97
|
+
taskId: session.taskId,
|
|
98
|
+
state: finalState,
|
|
99
|
+
resumed: Boolean(session.runtimeSessionId),
|
|
100
|
+
exitCode,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
printSuccess(`${brand(agent)} [${session.capability}] → ${finalState}`);
|
|
105
|
+
printInfo(` resumed: ${session.runtimeSessionId ? "yes (warm)" : "no (cold)"}`);
|
|
106
|
+
printInfo(` worktree:${muted(` ${session.worktreePath}`)}`);
|
|
107
|
+
} finally {
|
|
108
|
+
events.close();
|
|
109
|
+
mail.close();
|
|
110
|
+
store.close();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -45,6 +45,13 @@ describe("loadConfig", () => {
|
|
|
45
45
|
expect(cfg.merge.aiResolveEnabled).toBe(true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test("an old config without merge.autoMerge inherits the 'off' default (deep merge)", () => {
|
|
49
|
+
writeConfig(root, "config.yaml", "merge:\n aiResolveEnabled: true\n");
|
|
50
|
+
const cfg = loadConfig(root);
|
|
51
|
+
expect(cfg.merge.autoMerge).toBe("off");
|
|
52
|
+
expect(cfg.merge.aiResolveEnabled).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
48
55
|
test("config.local.yaml overrides config.yaml", () => {
|
|
49
56
|
writeConfig(root, "config.yaml", "runtime:\n default: claude\n");
|
|
50
57
|
writeConfig(root, "config.local.yaml", "runtime:\n default: gemini\n");
|
|
@@ -74,6 +81,17 @@ describe("validateConfig", () => {
|
|
|
74
81
|
test("accepts the defaults", () => {
|
|
75
82
|
expect(() => validateConfig(structuredClone(DEFAULT_CONFIG))).not.toThrow();
|
|
76
83
|
});
|
|
84
|
+
|
|
85
|
+
test("auto-merge defaults to off", () => {
|
|
86
|
+
expect(DEFAULT_CONFIG.merge.autoMerge).toBe("off");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects an unknown merge.autoMerge mode", () => {
|
|
90
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
91
|
+
// @ts-expect-error intentionally invalid value
|
|
92
|
+
cfg.merge.autoMerge = "always";
|
|
93
|
+
expect(() => validateConfig(cfg)).toThrow();
|
|
94
|
+
});
|
|
77
95
|
});
|
|
78
96
|
|
|
79
97
|
describe("isInitialized / serializeConfig", () => {
|
package/src/config.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
13
13
|
import { join, resolve } from "node:path";
|
|
14
14
|
import yaml from "js-yaml";
|
|
15
15
|
import { ConfigError } from "./errors.ts";
|
|
16
|
-
import type { AgentplateConfig } from "./types.ts";
|
|
16
|
+
import type { AgentplateConfig, AutoMergeMode } from "./types.ts";
|
|
17
17
|
|
|
18
18
|
/** Directory (relative to project root) holding all Agentplate state. */
|
|
19
19
|
export const AGENTPLATE_DIR = ".agentplate";
|
|
@@ -46,6 +46,7 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
|
|
|
46
46
|
},
|
|
47
47
|
merge: {
|
|
48
48
|
aiResolveEnabled: true,
|
|
49
|
+
autoMerge: "off",
|
|
49
50
|
},
|
|
50
51
|
skills: {
|
|
51
52
|
enabled: true,
|
|
@@ -175,6 +176,10 @@ export function validateConfig(config: AgentplateConfig): void {
|
|
|
175
176
|
"config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
|
|
176
177
|
);
|
|
177
178
|
}
|
|
179
|
+
const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
|
|
180
|
+
if (!autoMergeModes.includes(config.merge.autoMerge)) {
|
|
181
|
+
throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
|
|
182
|
+
}
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
/**
|
package/src/errors.ts
CHANGED
|
@@ -63,6 +63,17 @@ export class NotFoundError extends AgentplateError {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A spawn was refused because it would exceed a configured orchestration limit
|
|
68
|
+
* (maxConcurrent / maxAgentsPerLead / maxDepth). Distinct exit code so callers
|
|
69
|
+
* (a lead/coordinator) can recognize "at capacity — back off and retry later".
|
|
70
|
+
*/
|
|
71
|
+
export class CapacityError extends AgentplateError {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message, "CAPACITY_EXCEEDED", 5);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
/** Type guard: is the given value a AgentplateError? */
|
|
67
78
|
export function isAgentplateError(value: unknown): value is AgentplateError {
|
|
68
79
|
return value instanceof AgentplateError;
|
package/src/index.ts
CHANGED
|
@@ -30,9 +30,11 @@ import { createSetupCommand } from "./commands/setup.ts";
|
|
|
30
30
|
import { createShipCommand } from "./commands/ship.ts";
|
|
31
31
|
import { createSkillCommand } from "./commands/skill.ts";
|
|
32
32
|
import { createSlingCommand } from "./commands/sling.ts";
|
|
33
|
+
import { createSpecCommand } from "./commands/spec.ts";
|
|
33
34
|
import { createStatusCommand } from "./commands/status.ts";
|
|
34
35
|
import { createStopCommand } from "./commands/stop.ts";
|
|
35
36
|
import { createTuiCommand } from "./commands/tui.ts";
|
|
37
|
+
import { createTurnCommand } from "./commands/turn.ts";
|
|
36
38
|
import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
37
39
|
import { setProjectRootOverride } from "./config.ts";
|
|
38
40
|
import { isAgentplateError } from "./errors.ts";
|
|
@@ -95,6 +97,8 @@ function buildProgram(): Command {
|
|
|
95
97
|
// Orchestration
|
|
96
98
|
program.addCommand(createCoordinatorCommand());
|
|
97
99
|
program.addCommand(createSlingCommand());
|
|
100
|
+
program.addCommand(createTurnCommand());
|
|
101
|
+
program.addCommand(createSpecCommand());
|
|
98
102
|
program.addCommand(createStatusCommand());
|
|
99
103
|
program.addCommand(createMailCommand());
|
|
100
104
|
program.addCommand(createMergeCommand());
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for runQualityGates. Real subprocesses (no mocks) — gates are shell
|
|
3
|
+
* commands, so we use `true`/`false`/`sleep` to exercise pass/fail/partial and to
|
|
4
|
+
* prove the gates run concurrently (wall-clock < sum of durations).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { QualityGate } from "../types.ts";
|
|
9
|
+
import { runQualityGates } from "./quality-gates.ts";
|
|
10
|
+
|
|
11
|
+
const gate = (name: string, command: string): QualityGate => ({ name, command });
|
|
12
|
+
|
|
13
|
+
describe("runQualityGates", () => {
|
|
14
|
+
test("returns null when there are no gates", async () => {
|
|
15
|
+
expect(await runQualityGates([], process.cwd())).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("all passing → success", async () => {
|
|
19
|
+
const out = await runQualityGates([gate("a", "true"), gate("b", "true")], process.cwd());
|
|
20
|
+
expect(out?.status).toBe("success");
|
|
21
|
+
expect(out?.results.every((r) => r.passed)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("all failing → failure", async () => {
|
|
25
|
+
const out = await runQualityGates([gate("a", "false"), gate("b", "false")], process.cwd());
|
|
26
|
+
expect(out?.status).toBe("failure");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mixed → partial", async () => {
|
|
30
|
+
const out = await runQualityGates([gate("ok", "true"), gate("bad", "false")], process.cwd());
|
|
31
|
+
expect(out?.status).toBe("partial");
|
|
32
|
+
// Order is preserved across the concurrent run.
|
|
33
|
+
expect(out?.results.map((r) => r.name)).toEqual(["ok", "bad"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("gates run concurrently (wall-clock well under the sum)", async () => {
|
|
37
|
+
const gates = [gate("s1", "sleep 0.3"), gate("s2", "sleep 0.3"), gate("s3", "sleep 0.3")];
|
|
38
|
+
const out = await runQualityGates(gates, process.cwd());
|
|
39
|
+
expect(out?.status).toBe("success");
|
|
40
|
+
// Sequential would be ~900ms; concurrent should be well under that.
|
|
41
|
+
expect(out?.totalDurationMs ?? Number.POSITIVE_INFINITY).toBeLessThan(700);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -34,37 +34,36 @@ export async function runQualityGates(
|
|
|
34
34
|
): Promise<QualityGateOutcome | null> {
|
|
35
35
|
if (gates.length === 0) return null;
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
stderr: "pipe"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
37
|
+
// Gates are independent checks, so run them concurrently and measure the
|
|
38
|
+
// outcome by wall-clock — `totalDurationMs` is the elapsed time of the whole
|
|
39
|
+
// batch (the slowest gate), not the sum of all gates.
|
|
40
|
+
const overallStart = performance.now();
|
|
41
|
+
const results: GateResult[] = await Promise.all(
|
|
42
|
+
gates.map(async (gate): Promise<GateResult> => {
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
let exitCode = 1;
|
|
45
|
+
try {
|
|
46
|
+
// Run the gate through the platform shell: `cmd /c` on Windows (no bash
|
|
47
|
+
// there), `bash -lc` elsewhere. `.cmd` shims (biome/tsc) resolve under both.
|
|
48
|
+
const shellArgv =
|
|
49
|
+
process.platform === "win32"
|
|
50
|
+
? ["cmd", "/d", "/s", "/c", gate.command]
|
|
51
|
+
: ["bash", "-lc", gate.command];
|
|
52
|
+
const proc = Bun.spawn(shellArgv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
exitCode = await proc.exited;
|
|
54
|
+
} catch {
|
|
55
|
+
exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: gate.name,
|
|
59
|
+
command: gate.command,
|
|
60
|
+
passed: exitCode === 0,
|
|
61
|
+
exitCode,
|
|
62
|
+
durationMs: Math.round(performance.now() - started),
|
|
63
|
+
};
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const total = Math.round(performance.now() - overallStart);
|
|
68
67
|
|
|
69
68
|
const passed = results.filter((r) => r.passed).length;
|
|
70
69
|
const status: OutcomeStatus =
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auto-merge (maybeAutoMerge).
|
|
3
|
+
*
|
|
4
|
+
* Real git repos in temp dirs (per "never mock what you can use for real"), the
|
|
5
|
+
* real merge queue + lock, and a recording mail stub so we can assert the outcome
|
|
6
|
+
* notifications without a mail DB. Covers the mode gate, the capability skip, the
|
|
7
|
+
* gates-pass fail-closed rule, a clean landing, and a reported conflict.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Capability, NewMail } from "../types.ts";
|
|
15
|
+
import { type AutoMergeParams, maybeAutoMerge } from "./auto.ts";
|
|
16
|
+
|
|
17
|
+
let repo: string;
|
|
18
|
+
|
|
19
|
+
async function git(...args: string[]): Promise<string> {
|
|
20
|
+
const proc = Bun.spawn(["git", ...args], { cwd: repo, stdout: "pipe", stderr: "pipe" });
|
|
21
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
22
|
+
new Response(proc.stdout).text(),
|
|
23
|
+
new Response(proc.stderr).text(),
|
|
24
|
+
proc.exited,
|
|
25
|
+
]);
|
|
26
|
+
if (exitCode !== 0) throw new Error(`git ${args.join(" ")} failed (${exitCode}): ${stderr}`);
|
|
27
|
+
return stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Recording mail stub satisfying the `{ send }` surface maybeAutoMerge needs. */
|
|
31
|
+
function recordingMail() {
|
|
32
|
+
const sent: Array<{ type: string; subject: string; to: string }> = [];
|
|
33
|
+
return {
|
|
34
|
+
sent,
|
|
35
|
+
send: (m: NewMail) => {
|
|
36
|
+
sent.push({ type: m.type, subject: m.subject, to: m.to });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build params with sensible defaults; override per test. */
|
|
42
|
+
function params(
|
|
43
|
+
over: Partial<AutoMergeParams> & { mail: ReturnType<typeof recordingMail> },
|
|
44
|
+
): AutoMergeParams {
|
|
45
|
+
return {
|
|
46
|
+
root: repo,
|
|
47
|
+
branchName: "agentplate/builder-x",
|
|
48
|
+
targetBranch: "main",
|
|
49
|
+
capability: "builder" as Capability,
|
|
50
|
+
agentName: "builder-x",
|
|
51
|
+
taskId: "task-x",
|
|
52
|
+
parent: "lead-1",
|
|
53
|
+
mode: "on-complete",
|
|
54
|
+
aiResolveEnabled: true,
|
|
55
|
+
gateStatus: null,
|
|
56
|
+
...over,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
repo = mkdtempSync(join(tmpdir(), "agentplate-automerge-"));
|
|
62
|
+
mkdirSync(join(repo, ".agentplate"), { recursive: true });
|
|
63
|
+
await git("init", "-q");
|
|
64
|
+
await git("config", "user.email", "test@agentplate.dev");
|
|
65
|
+
await git("config", "user.name", "Agentplate Test");
|
|
66
|
+
await git("checkout", "-q", "-b", "main");
|
|
67
|
+
await Bun.write(join(repo, "base.txt"), "base\n");
|
|
68
|
+
await git("add", "-A");
|
|
69
|
+
await git("commit", "-q", "-m", "initial");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
rmSync(repo, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Create a worker branch with a new-file commit, then return to main. */
|
|
77
|
+
async function workerBranchWithCommit(branch: string, file: string): Promise<void> {
|
|
78
|
+
await git("checkout", "-q", "-b", branch);
|
|
79
|
+
await Bun.write(join(repo, file), "from worker\n");
|
|
80
|
+
await git("add", "-A");
|
|
81
|
+
await git("commit", "-q", "-m", `add ${file}`);
|
|
82
|
+
await git("checkout", "-q", "main");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("maybeAutoMerge — gating", () => {
|
|
86
|
+
test("mode 'off' never merges", async () => {
|
|
87
|
+
const mail = recordingMail();
|
|
88
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
89
|
+
const out = await maybeAutoMerge(params({ mail, mode: "off" }));
|
|
90
|
+
expect(out).toEqual({ merged: false, reason: "disabled" });
|
|
91
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
92
|
+
expect(mail.sent).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("read-only capabilities are skipped", async () => {
|
|
96
|
+
const mail = recordingMail();
|
|
97
|
+
for (const capability of ["scout", "merger"] as Capability[]) {
|
|
98
|
+
const out = await maybeAutoMerge(params({ mail, capability }));
|
|
99
|
+
expect(out).toEqual({ merged: false, reason: "capability-skipped" });
|
|
100
|
+
}
|
|
101
|
+
expect(mail.sent).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("on-gates-pass holds (fail-closed) when gates did not pass", async () => {
|
|
105
|
+
const mail = recordingMail();
|
|
106
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
107
|
+
for (const gateStatus of [null, "failure", "partial"] as const) {
|
|
108
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-gates-pass", gateStatus }));
|
|
109
|
+
expect(out).toEqual({ merged: false, reason: "gates-not-passed" });
|
|
110
|
+
}
|
|
111
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
112
|
+
expect(mail.sent.every((m) => m.type === "status")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("maybeAutoMerge — landing", () => {
|
|
117
|
+
test("on-complete lands the branch and reports 'merged'", async () => {
|
|
118
|
+
const mail = recordingMail();
|
|
119
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
120
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-complete" }));
|
|
121
|
+
expect(out).toEqual({ merged: true, status: "merged", tier: "clean-merge" });
|
|
122
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true); // landed on main
|
|
123
|
+
expect(mail.sent).toHaveLength(1);
|
|
124
|
+
expect(mail.sent[0]).toMatchObject({ type: "merged", to: "lead-1" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("on-gates-pass merges on a clean gate success", async () => {
|
|
128
|
+
const mail = recordingMail();
|
|
129
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
130
|
+
const out = await maybeAutoMerge(
|
|
131
|
+
params({ mail, mode: "on-gates-pass", gateStatus: "success" }),
|
|
132
|
+
);
|
|
133
|
+
expect(out).toMatchObject({ merged: true, status: "merged" });
|
|
134
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an unresolved conflict reports 'merge_failed' (never throws)", async () => {
|
|
138
|
+
const mail = recordingMail();
|
|
139
|
+
// Both branches edit base.txt differently -> conflict; aiResolveEnabled=false
|
|
140
|
+
// makes mergeBranch abort and fail.
|
|
141
|
+
await git("checkout", "-q", "-b", "agentplate/builder-x");
|
|
142
|
+
await Bun.write(join(repo, "base.txt"), "worker change\n");
|
|
143
|
+
await git("add", "-A");
|
|
144
|
+
await git("commit", "-q", "-m", "worker edits base");
|
|
145
|
+
await git("checkout", "-q", "main");
|
|
146
|
+
await Bun.write(join(repo, "base.txt"), "main change\n");
|
|
147
|
+
await git("add", "-A");
|
|
148
|
+
await git("commit", "-q", "-m", "main edits base");
|
|
149
|
+
|
|
150
|
+
const out = await maybeAutoMerge(
|
|
151
|
+
params({ mail, mode: "on-complete", aiResolveEnabled: false }),
|
|
152
|
+
);
|
|
153
|
+
expect(out.merged).toBe(false);
|
|
154
|
+
expect(out).toMatchObject({ reason: "merge-failed" });
|
|
155
|
+
expect(mail.sent[0]).toMatchObject({ type: "merge_failed" });
|
|
156
|
+
});
|
|
157
|
+
});
|