@agentplate/cli 1.0.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate worktree` — list and clean agent git worktrees.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { findProjectRoot, isInitialized } from "../config.ts";
|
|
7
|
+
import { ValidationError } from "../errors.ts";
|
|
8
|
+
import { jsonOutput } from "../json.ts";
|
|
9
|
+
import { muted, printInfo, printSuccess } from "../logging/color.ts";
|
|
10
|
+
import { sessionsDbPath, worktreesDir } from "../paths.ts";
|
|
11
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
12
|
+
import { listWorktrees, removeWorktree } from "../worktree/manager.ts";
|
|
13
|
+
|
|
14
|
+
function requireInit(): string {
|
|
15
|
+
const root = findProjectRoot();
|
|
16
|
+
if (!isInitialized(root)) {
|
|
17
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listCommand(): Command {
|
|
23
|
+
return new Command("list")
|
|
24
|
+
.description("List git worktrees")
|
|
25
|
+
.option("--json", "output JSON")
|
|
26
|
+
.action(async (_opts: { json?: boolean }, command: Command) => {
|
|
27
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
28
|
+
const root = requireInit();
|
|
29
|
+
const worktrees = await listWorktrees(root);
|
|
30
|
+
if (useJson) {
|
|
31
|
+
jsonOutput(worktrees);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const w of worktrees) printInfo(`${w.branch || "(detached)"} ${muted(w.path)}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanCommand(): Command {
|
|
39
|
+
return new Command("clean")
|
|
40
|
+
.description("Remove agent worktrees")
|
|
41
|
+
.option("--completed", "only worktrees of completed/stopped agents")
|
|
42
|
+
.option("--all", "all agent worktrees")
|
|
43
|
+
.option("--force", "remove even if dirty/unmerged")
|
|
44
|
+
.action(async (opts: { completed?: boolean; all?: boolean; force?: boolean }) => {
|
|
45
|
+
const root = requireInit();
|
|
46
|
+
if (!opts.completed && !opts.all) {
|
|
47
|
+
throw new ValidationError("Pass --completed or --all.");
|
|
48
|
+
}
|
|
49
|
+
const base = worktreesDir(root);
|
|
50
|
+
const worktrees = (await listWorktrees(root)).filter((w) => w.path.startsWith(base));
|
|
51
|
+
|
|
52
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
53
|
+
let removed = 0;
|
|
54
|
+
try {
|
|
55
|
+
const sessions = store.listSessions();
|
|
56
|
+
for (const w of worktrees) {
|
|
57
|
+
if (opts.completed) {
|
|
58
|
+
const session = sessions.find((s) => s.worktreePath === w.path);
|
|
59
|
+
const done = session && (session.state === "completed" || session.state === "stopped");
|
|
60
|
+
if (!done) continue;
|
|
61
|
+
}
|
|
62
|
+
await removeWorktree(root, w.path, { force: opts.force });
|
|
63
|
+
removed++;
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
store.close();
|
|
67
|
+
}
|
|
68
|
+
printSuccess(`Removed ${removed} worktree(s)`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createWorktreeCommand(): Command {
|
|
73
|
+
return new Command("worktree")
|
|
74
|
+
.description("Manage agent git worktrees")
|
|
75
|
+
.addCommand(listCommand())
|
|
76
|
+
.addCommand(cleanCommand());
|
|
77
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CONFIG,
|
|
7
|
+
isInitialized,
|
|
8
|
+
loadConfig,
|
|
9
|
+
serializeConfig,
|
|
10
|
+
setProjectRootOverride,
|
|
11
|
+
validateConfig,
|
|
12
|
+
} from "./config.ts";
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-cfg-"));
|
|
18
|
+
mkdirSync(join(root, ".agentplate"), { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
setProjectRootOverride(null);
|
|
23
|
+
rmSync(root, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function writeConfig(dir: string, file: string, contents: string): void {
|
|
27
|
+
writeFileSync(join(dir, ".agentplate", file), contents, "utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("loadConfig", () => {
|
|
31
|
+
test("returns defaults when only an empty config.yaml exists", () => {
|
|
32
|
+
writeConfig(root, "config.yaml", "");
|
|
33
|
+
const cfg = loadConfig(root);
|
|
34
|
+
expect(cfg.runtime.default).toBe(DEFAULT_CONFIG.runtime.default);
|
|
35
|
+
expect(cfg.project.root).toBe(root);
|
|
36
|
+
expect(cfg.agents.maxDepth).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("merges config.yaml over defaults", () => {
|
|
40
|
+
writeConfig(root, "config.yaml", "project:\n name: my-app\nruntime:\n default: codex\n");
|
|
41
|
+
const cfg = loadConfig(root);
|
|
42
|
+
expect(cfg.project.name).toBe("my-app");
|
|
43
|
+
expect(cfg.runtime.default).toBe("codex");
|
|
44
|
+
// Untouched defaults remain.
|
|
45
|
+
expect(cfg.merge.aiResolveEnabled).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("config.local.yaml overrides config.yaml", () => {
|
|
49
|
+
writeConfig(root, "config.yaml", "runtime:\n default: claude\n");
|
|
50
|
+
writeConfig(root, "config.local.yaml", "runtime:\n default: gemini\n");
|
|
51
|
+
const cfg = loadConfig(root);
|
|
52
|
+
expect(cfg.runtime.default).toBe("gemini");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("throws ConfigError on invalid YAML", () => {
|
|
56
|
+
writeConfig(root, "config.yaml", "project:\n name: [unclosed\n");
|
|
57
|
+
expect(() => loadConfig(root)).toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("validateConfig", () => {
|
|
62
|
+
test("rejects negative maxDepth", () => {
|
|
63
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
64
|
+
cfg.agents.maxDepth = -1;
|
|
65
|
+
expect(() => validateConfig(cfg)).toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects maxConcurrent below 1", () => {
|
|
69
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
70
|
+
cfg.agents.maxConcurrent = 0;
|
|
71
|
+
expect(() => validateConfig(cfg)).toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("accepts the defaults", () => {
|
|
75
|
+
expect(() => validateConfig(structuredClone(DEFAULT_CONFIG))).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isInitialized / serializeConfig", () => {
|
|
80
|
+
test("isInitialized reflects presence of config.yaml", () => {
|
|
81
|
+
expect(isInitialized(root)).toBe(false);
|
|
82
|
+
writeConfig(root, "config.yaml", "");
|
|
83
|
+
expect(isInitialized(root)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("serializeConfig round-trips through loadConfig", () => {
|
|
87
|
+
const yamlText = serializeConfig(DEFAULT_CONFIG);
|
|
88
|
+
writeConfig(root, "config.yaml", yamlText);
|
|
89
|
+
const cfg = loadConfig(root);
|
|
90
|
+
expect(cfg.runtime.default).toBe(DEFAULT_CONFIG.runtime.default);
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loading, defaults, and validation.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (later overrides earlier):
|
|
5
|
+
* DEFAULT_CONFIG ← .agentplate/config.yaml ← .agentplate/config.local.yaml
|
|
6
|
+
*
|
|
7
|
+
* `config.local.yaml` is gitignored and holds machine-specific overrides.
|
|
8
|
+
* Secrets never live in any of these files — only the *names* of env vars that
|
|
9
|
+
* hold them (see {@link ProviderConfig.authTokenEnv}).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import yaml from "js-yaml";
|
|
15
|
+
import { ConfigError } from "./errors.ts";
|
|
16
|
+
import type { AgentplateConfig } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** Directory (relative to project root) holding all Agentplate state. */
|
|
19
|
+
export const AGENTPLATE_DIR = ".agentplate";
|
|
20
|
+
/** Primary committed config file. */
|
|
21
|
+
export const CONFIG_FILE = "config.yaml";
|
|
22
|
+
/** Gitignored machine-specific overrides. */
|
|
23
|
+
export const CONFIG_LOCAL_FILE = "config.local.yaml";
|
|
24
|
+
|
|
25
|
+
/** Built-in defaults applied beneath any on-disk config. */
|
|
26
|
+
export const DEFAULT_CONFIG: AgentplateConfig = {
|
|
27
|
+
project: {
|
|
28
|
+
name: "",
|
|
29
|
+
root: "",
|
|
30
|
+
canonicalBranch: "main",
|
|
31
|
+
},
|
|
32
|
+
runtime: {
|
|
33
|
+
default: "claude",
|
|
34
|
+
},
|
|
35
|
+
activeProvider: "anthropic",
|
|
36
|
+
providers: {
|
|
37
|
+
anthropic: { type: "native", authTokenEnv: "ANTHROPIC_API_KEY" },
|
|
38
|
+
},
|
|
39
|
+
agents: {
|
|
40
|
+
manifestPath: ".agentplate/agent-manifest.json",
|
|
41
|
+
baseDir: ".agentplate/agent-defs",
|
|
42
|
+
maxConcurrent: 10,
|
|
43
|
+
maxDepth: 2,
|
|
44
|
+
maxAgentsPerLead: 5,
|
|
45
|
+
idleTimeoutMinutes: 10,
|
|
46
|
+
},
|
|
47
|
+
merge: {
|
|
48
|
+
aiResolveEnabled: true,
|
|
49
|
+
},
|
|
50
|
+
skills: {
|
|
51
|
+
enabled: true,
|
|
52
|
+
retrieval: {
|
|
53
|
+
budgetChars: 6000,
|
|
54
|
+
maxFull: 4,
|
|
55
|
+
},
|
|
56
|
+
distill: {
|
|
57
|
+
onlyOnGatesPass: true,
|
|
58
|
+
model: null,
|
|
59
|
+
},
|
|
60
|
+
prune: {
|
|
61
|
+
quarantineBelow: 0.25,
|
|
62
|
+
minSamples: 4,
|
|
63
|
+
maxAgeDays: 30,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
deploy: {
|
|
67
|
+
default: "",
|
|
68
|
+
targets: {},
|
|
69
|
+
gates: {
|
|
70
|
+
production: "confirm",
|
|
71
|
+
staging: "auto",
|
|
72
|
+
preview: "auto",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
logging: {
|
|
76
|
+
verbose: false,
|
|
77
|
+
redactSecrets: true,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let projectRootOverride: string | null = null;
|
|
82
|
+
|
|
83
|
+
/** Override project-root auto-detection (used by `--project` and tests). */
|
|
84
|
+
export function setProjectRootOverride(root: string | null): void {
|
|
85
|
+
projectRootOverride = root === null ? null : resolve(root);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find the project root by walking up from `start` looking for a `.agentplate/`
|
|
90
|
+
* directory. Falls back to the override or the current working directory.
|
|
91
|
+
*/
|
|
92
|
+
export function findProjectRoot(start: string = process.cwd()): string {
|
|
93
|
+
if (projectRootOverride) return projectRootOverride;
|
|
94
|
+
let dir = resolve(start);
|
|
95
|
+
while (true) {
|
|
96
|
+
if (existsSync(join(dir, AGENTPLATE_DIR))) return dir;
|
|
97
|
+
const parent = resolve(dir, "..");
|
|
98
|
+
if (parent === dir) break;
|
|
99
|
+
dir = parent;
|
|
100
|
+
}
|
|
101
|
+
return resolve(start);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Has Agentplate been initialized at `root`? */
|
|
105
|
+
export function isInitialized(root: string): boolean {
|
|
106
|
+
return existsSync(join(root, AGENTPLATE_DIR, CONFIG_FILE));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Deep-merge plain objects from `source` into a clone of `base`. */
|
|
110
|
+
function deepMerge<T>(base: T, source: unknown): T {
|
|
111
|
+
if (source === null || source === undefined) return base;
|
|
112
|
+
if (typeof base !== "object" || base === null || Array.isArray(base)) {
|
|
113
|
+
return source as T;
|
|
114
|
+
}
|
|
115
|
+
if (typeof source !== "object" || Array.isArray(source)) {
|
|
116
|
+
return source as T;
|
|
117
|
+
}
|
|
118
|
+
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
119
|
+
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
|
120
|
+
const baseValue = (base as Record<string, unknown>)[key];
|
|
121
|
+
result[key] = deepMerge(baseValue, value);
|
|
122
|
+
}
|
|
123
|
+
return result as T;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse a YAML file into a plain object, or return `{}` if it is absent. */
|
|
127
|
+
function readYamlIfExists(path: string): Record<string, unknown> {
|
|
128
|
+
if (!existsSync(path)) return {};
|
|
129
|
+
let raw: string;
|
|
130
|
+
try {
|
|
131
|
+
raw = readFileSync(path, "utf8");
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new ConfigError(`Failed to read ${path}: ${(error as Error).message}`);
|
|
134
|
+
}
|
|
135
|
+
let parsed: unknown;
|
|
136
|
+
try {
|
|
137
|
+
parsed = yaml.load(raw);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw new ConfigError(`Invalid YAML in ${path}: ${(error as Error).message}`);
|
|
140
|
+
}
|
|
141
|
+
if (parsed === null || parsed === undefined) return {};
|
|
142
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
143
|
+
throw new ConfigError(`Expected a mapping at the top of ${path}`);
|
|
144
|
+
}
|
|
145
|
+
return parsed as Record<string, unknown>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validate a fully-merged config. Throws {@link ConfigError} on the first
|
|
150
|
+
* problem found. Kept intentionally small in Phase 0; extended per phase.
|
|
151
|
+
*/
|
|
152
|
+
export function validateConfig(config: AgentplateConfig): void {
|
|
153
|
+
if (!config.project || typeof config.project.canonicalBranch !== "string") {
|
|
154
|
+
throw new ConfigError("config.project.canonicalBranch must be a string");
|
|
155
|
+
}
|
|
156
|
+
if (!config.runtime || typeof config.runtime.default !== "string") {
|
|
157
|
+
throw new ConfigError("config.runtime.default must be a string");
|
|
158
|
+
}
|
|
159
|
+
if (typeof config.activeProvider !== "string" || config.activeProvider === "") {
|
|
160
|
+
throw new ConfigError("config.activeProvider must be a non-empty string");
|
|
161
|
+
}
|
|
162
|
+
if (!config.providers[config.activeProvider]) {
|
|
163
|
+
throw new ConfigError(
|
|
164
|
+
`config.activeProvider "${config.activeProvider}" is not present in config.providers`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (config.agents.maxDepth < 0) {
|
|
168
|
+
throw new ConfigError("config.agents.maxDepth must be >= 0");
|
|
169
|
+
}
|
|
170
|
+
if (config.agents.maxConcurrent < 1) {
|
|
171
|
+
throw new ConfigError("config.agents.maxConcurrent must be >= 1");
|
|
172
|
+
}
|
|
173
|
+
if (config.agents.idleTimeoutMinutes < 0) {
|
|
174
|
+
throw new ConfigError(
|
|
175
|
+
"config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load and validate the Agentplate config for the given project root (auto-detected
|
|
182
|
+
* if omitted). Merges defaults ← config.yaml ← config.local.yaml and stamps the
|
|
183
|
+
* resolved `project.root`.
|
|
184
|
+
*/
|
|
185
|
+
export function loadConfig(root?: string): AgentplateConfig {
|
|
186
|
+
const projectRoot = root ? resolve(root) : findProjectRoot();
|
|
187
|
+
const dir = join(projectRoot, AGENTPLATE_DIR);
|
|
188
|
+
const base = readYamlIfExists(join(dir, CONFIG_FILE));
|
|
189
|
+
const local = readYamlIfExists(join(dir, CONFIG_LOCAL_FILE));
|
|
190
|
+
|
|
191
|
+
let merged = deepMerge(DEFAULT_CONFIG, base);
|
|
192
|
+
merged = deepMerge(merged, local);
|
|
193
|
+
merged.project.root = projectRoot;
|
|
194
|
+
|
|
195
|
+
validateConfig(merged);
|
|
196
|
+
return merged;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Serialize a config object to YAML for writing to `config.yaml`. */
|
|
200
|
+
export function serializeConfig(config: AgentplateConfig): string {
|
|
201
|
+
return yaml.dump(config, { indent: 2, lineWidth: 100, sortKeys: false });
|
|
202
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { openDatabase } from "./sqlite.ts";
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), "agentplate-db-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("openDatabase", () => {
|
|
19
|
+
test("opens a fresh DB with WAL", () => {
|
|
20
|
+
const path = join(dir, "fresh.db");
|
|
21
|
+
const db = openDatabase(path);
|
|
22
|
+
const mode = db.query("PRAGMA journal_mode").get() as { journal_mode: string };
|
|
23
|
+
expect(mode.journal_mode.toLowerCase()).toBe("wal");
|
|
24
|
+
db.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("guard leaves a compatible DB untouched", () => {
|
|
28
|
+
const path = join(dir, "ok.db");
|
|
29
|
+
// Seed a DB whose `runs` table HAS the required column.
|
|
30
|
+
const seed = new Database(path, { create: true });
|
|
31
|
+
seed.exec("CREATE TABLE runs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)");
|
|
32
|
+
seed.exec("INSERT INTO runs (id, created_at) VALUES ('r1', 'now')");
|
|
33
|
+
seed.close();
|
|
34
|
+
|
|
35
|
+
const db = openDatabase(path, { guard: { table: "runs", columns: ["created_at"] } });
|
|
36
|
+
const row = db.query("SELECT id FROM runs WHERE id = 'r1'").get() as { id: string } | null;
|
|
37
|
+
expect(row?.id).toBe("r1"); // data preserved — no recreation
|
|
38
|
+
db.close();
|
|
39
|
+
// No backup file created.
|
|
40
|
+
expect(readdirSync(dir).some((f) => f.includes("incompatible"))).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("guard backs up and recreates an incompatible (foreign) DB", () => {
|
|
44
|
+
const path = join(dir, "foreign.db");
|
|
45
|
+
// Seed a DB shaped like the OTHER agentplate: `runs` has `started_at`,
|
|
46
|
+
// not `created_at`.
|
|
47
|
+
const seed = new Database(path, { create: true });
|
|
48
|
+
seed.exec(
|
|
49
|
+
"CREATE TABLE runs (id TEXT PRIMARY KEY, started_at TEXT NOT NULL, agent_count INTEGER)",
|
|
50
|
+
);
|
|
51
|
+
seed.exec("INSERT INTO runs (id, started_at, agent_count) VALUES ('old', 'then', 3)");
|
|
52
|
+
seed.close();
|
|
53
|
+
|
|
54
|
+
const db = openDatabase(path, { guard: { table: "runs", columns: ["created_at"] } });
|
|
55
|
+
// The foreign `runs` table is gone; we can now create ours and insert.
|
|
56
|
+
db.exec("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)");
|
|
57
|
+
db.exec("INSERT INTO runs (id, created_at) VALUES ('new', 'now')");
|
|
58
|
+
const cols = db.query("PRAGMA table_info(runs)").all() as Array<{ name: string }>;
|
|
59
|
+
expect(cols.map((c) => c.name)).toContain("created_at");
|
|
60
|
+
// The old row is NOT present (fresh DB).
|
|
61
|
+
const old = db.query("SELECT id FROM runs WHERE id = 'old'").get();
|
|
62
|
+
expect(old).toBeNull();
|
|
63
|
+
db.close();
|
|
64
|
+
|
|
65
|
+
// A backup of the incompatible file exists.
|
|
66
|
+
expect(readdirSync(dir).some((f) => f.startsWith("foreign.db.incompatible-"))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("guard ignores an absent table (fresh project)", () => {
|
|
70
|
+
const path = join(dir, "empty.db");
|
|
71
|
+
const db = openDatabase(path, { guard: { table: "runs", columns: ["created_at"] } });
|
|
72
|
+
// No backup, no error — our CREATE TABLE will make it correctly.
|
|
73
|
+
db.exec("CREATE TABLE runs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)");
|
|
74
|
+
db.close();
|
|
75
|
+
expect(readdirSync(dir).some((f) => f.includes("incompatible"))).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/db/sqlite.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite helper.
|
|
3
|
+
*
|
|
4
|
+
* Every Agentplate store (mail, sessions, events, skills, deploys, merge queue)
|
|
5
|
+
* opens its database through {@link openDatabase} so WAL mode and a busy timeout
|
|
6
|
+
* are applied consistently — both required for safe concurrent access from
|
|
7
|
+
* multiple agent processes.
|
|
8
|
+
*
|
|
9
|
+
* Self-healing schema guard: `.agentplate/*.db` files are gitignored, regenerable
|
|
10
|
+
* runtime state. Because this tool shares the `.agentplate/` directory name (and
|
|
11
|
+
* db filenames) with the unrelated `@ag-eco/agentplate-cli`, a project that used
|
|
12
|
+
* the other tool can leave behind a `sessions.db`/`mail.db` with a *different*
|
|
13
|
+
* schema. `CREATE TABLE IF NOT EXISTS` then silently keeps the foreign table and
|
|
14
|
+
* later inserts fail (e.g. "table runs has no column named created_at"). The
|
|
15
|
+
* optional {@link OpenDatabaseOptions.guard} detects that case, backs up the
|
|
16
|
+
* incompatible file, and recreates a clean database.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { existsSync, renameSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
/** A required-columns check used to detect a foreign/stale schema. */
|
|
23
|
+
export interface SchemaGuard {
|
|
24
|
+
/** Table that must exist with the listed columns (if it exists at all). */
|
|
25
|
+
table: string;
|
|
26
|
+
/** Columns our schema requires on that table. */
|
|
27
|
+
columns: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OpenDatabaseOptions {
|
|
31
|
+
/** Open read-only (no schema creation). */
|
|
32
|
+
readonly?: boolean;
|
|
33
|
+
/** Busy timeout in milliseconds (default 5000). */
|
|
34
|
+
busyTimeoutMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Verify an existing file-backed DB has our schema. If the guarded table
|
|
37
|
+
* exists but is missing any required column, the DB was created by a
|
|
38
|
+
* different tool/version; the file (and -wal/-shm) is renamed to a
|
|
39
|
+
* `.incompatible-<ts>` backup and a fresh DB is created in its place.
|
|
40
|
+
*/
|
|
41
|
+
guard?: SchemaGuard;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Apply our standard PRAGMAs to a freshly opened connection. */
|
|
45
|
+
function applyPragmas(db: Database, busyTimeoutMs: number): void {
|
|
46
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
47
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
48
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Does `table` exist but lack one of the required columns? */
|
|
52
|
+
function tableIsIncompatible(db: Database, guard: SchemaGuard): boolean {
|
|
53
|
+
const exists = db
|
|
54
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
55
|
+
.get(guard.table);
|
|
56
|
+
if (!exists) return false; // absent → our CREATE TABLE will make it correctly
|
|
57
|
+
const cols = db.query(`PRAGMA table_info(${guard.table})`).all() as Array<{ name: string }>;
|
|
58
|
+
const present = new Set(cols.map((c) => c.name));
|
|
59
|
+
return guard.columns.some((c) => !present.has(c));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Rename a stale DB file plus its WAL/SHM sidecars out of the way. */
|
|
63
|
+
function backupStaleDb(path: string): string {
|
|
64
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
65
|
+
const backup = `${path}.incompatible-${stamp}`;
|
|
66
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
67
|
+
const file = `${path}${suffix}`;
|
|
68
|
+
if (existsSync(file)) {
|
|
69
|
+
try {
|
|
70
|
+
renameSync(file, `${backup}${suffix}`);
|
|
71
|
+
} catch {
|
|
72
|
+
// Best-effort: if a sidecar can't be moved, continue; the main file
|
|
73
|
+
// rename is what matters for recreating a clean DB.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return backup;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Open (or create) a SQLite database with WAL journaling and a busy timeout.
|
|
82
|
+
* Pass `":memory:"` as the path for ephemeral test databases. When `guard` is
|
|
83
|
+
* provided and an existing file has an incompatible schema, the file is backed
|
|
84
|
+
* up and recreated.
|
|
85
|
+
*/
|
|
86
|
+
export function openDatabase(path: string, options: OpenDatabaseOptions = {}): Database {
|
|
87
|
+
const busyTimeoutMs = options.busyTimeoutMs ?? 5000;
|
|
88
|
+
const readonly = options.readonly ?? false;
|
|
89
|
+
|
|
90
|
+
let db = new Database(path, { readonly, create: true });
|
|
91
|
+
applyPragmas(db, busyTimeoutMs);
|
|
92
|
+
|
|
93
|
+
// Self-heal a foreign/stale schema (file-backed DBs only; never :memory:).
|
|
94
|
+
if (options.guard && !readonly && path !== ":memory:" && tableIsIncompatible(db, options.guard)) {
|
|
95
|
+
db.close();
|
|
96
|
+
backupStaleDb(path);
|
|
97
|
+
db = new Database(path, { readonly: false, create: true });
|
|
98
|
+
applyPragmas(db, busyTimeoutMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return db;
|
|
102
|
+
}
|