@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Deploy target adapter contract.
3
+ *
4
+ * A *DeployTarget* is the pluggable mechanism for shipping an app to one place
5
+ * (Docker+GHA, a PaaS, a cloud, Kubernetes, on-prem). It mirrors the runtime
6
+ * adapter split: pure mechanics, no AI, one file per target, resolved by a
7
+ * registry. The staged agent pipeline (architect → builder → devops → deployer →
8
+ * verifier) *calls* these methods; it never embeds target-specific shell.
9
+ *
10
+ * Secrets flow through {@link DeployTarget.buildSecretEnv} (env-by-name, never
11
+ * hardcoded), exactly like the runtime adapters' `buildEnv`.
12
+ */
13
+
14
+ /** A secret store handle: resolve a deploy secret value by its env-var name. */
15
+ export interface DeploySecretStore {
16
+ /** Get a secret value by env-var name (file store, then process.env). */
17
+ get(key: string): string | undefined;
18
+ /** Is a secret available for this env-var name? */
19
+ has(key: string): boolean;
20
+ }
21
+
22
+ /** What kind of app a target detected — drives config generation. Project-agnostic. */
23
+ export interface AppProfile {
24
+ language:
25
+ | "node"
26
+ | "bun"
27
+ | "python"
28
+ | "go"
29
+ | "rust"
30
+ | "java"
31
+ | "ruby"
32
+ | "php"
33
+ | "static"
34
+ | "unknown";
35
+ /** Framework if detectable (next, vite, fastapi, django, …). */
36
+ framework: string | null;
37
+ /** Long-running server | static site | batch job | serverless function. */
38
+ kind: "service" | "static" | "job" | "function";
39
+ /** Build command, or null when none is needed. */
40
+ buildCommand: string | null;
41
+ /** Start command for a service, or null. */
42
+ startCommand: string | null;
43
+ /** Listen port if known. */
44
+ port: number | null;
45
+ /** Detected lockfile family ("bun.lock", "package-lock.json", …). */
46
+ packageManager: string | null;
47
+ /** Runtime env var NAMES the app expects (never values). */
48
+ runtimeEnvKeys: string[];
49
+ }
50
+
51
+ /** Result of {@link DeployTarget.detect}. */
52
+ export interface DetectResult {
53
+ fit: boolean;
54
+ /** 0..1 confidence used to rank targets when auto-selecting. */
55
+ confidence: number;
56
+ profile: AppProfile;
57
+ reason: string;
58
+ }
59
+
60
+ /** A file the adapter wants written into the worktree. */
61
+ export interface GeneratedArtifact {
62
+ /** Path RELATIVE to the worktree root (e.g. "Dockerfile"). */
63
+ path: string;
64
+ content: string;
65
+ /** File mode (default 0o644). */
66
+ mode?: number;
67
+ kind: "dockerfile" | "ci" | "iac" | "manifest" | "helm" | "script" | "config" | "ignore";
68
+ }
69
+
70
+ /** Output of {@link DeployTarget.generateConfig}. */
71
+ export interface GeneratedConfig {
72
+ artifacts: GeneratedArtifact[];
73
+ /** Secret env-var NAMES this config needs at deploy time (names only). */
74
+ requiredSecretKeys: string[];
75
+ summary: string;
76
+ }
77
+
78
+ /** Everything a deploy/verify/rollback step needs. */
79
+ export interface DeployContext {
80
+ target: string;
81
+ /** "preview" | "staging" | "production" (extensible per target). */
82
+ environment: string;
83
+ worktreePath: string;
84
+ projectRoot: string;
85
+ profile: AppProfile;
86
+ /** Resolved secret env (KEY→value). Never persisted, never logged. */
87
+ secretEnv: Record<string, string>;
88
+ /** Non-secret target settings from config. */
89
+ settings: Record<string, string | number | boolean>;
90
+ /** When true: generate + plan only; no outward-facing mutation. */
91
+ dryRun: boolean;
92
+ runId: string | null;
93
+ agentName: string;
94
+ }
95
+
96
+ /** Result of a deploy/rollback execution. */
97
+ export interface DeployResult {
98
+ ok: boolean;
99
+ /** Live URL(s) the deploy produced (empty for non-URL targets). */
100
+ urls: string[];
101
+ /** Provider deployment id used by rollback (image digest, release, …). */
102
+ deploymentId: string | null;
103
+ /** Captured CLI output tail (already secret-redacted). */
104
+ log: string;
105
+ outputs: Record<string, string>;
106
+ errorMessage: string | null;
107
+ }
108
+
109
+ /** Health/smoke-check outcome from {@link DeployTarget.verify}. */
110
+ export interface VerifyResult {
111
+ healthy: boolean;
112
+ checks: Array<{ name: string; ok: boolean; detail: string }>;
113
+ probedUrl: string | null;
114
+ }
115
+
116
+ /** Capability guards declared by a target (checked by the engine before phases). */
117
+ export interface DeployCaps {
118
+ canRollback: boolean;
119
+ /** Irreversible in practice → always force a confirm gate. */
120
+ irreversible: boolean;
121
+ environments: string[];
122
+ requiresCredentials: boolean;
123
+ }
124
+
125
+ /** The contract every deploy target implements (shaped like AgentRuntime). */
126
+ export interface DeployTarget {
127
+ id: string;
128
+ readonly stability: "stable" | "beta" | "experimental";
129
+ readonly label: string;
130
+ readonly description: string;
131
+ readonly caps: DeployCaps;
132
+
133
+ /** Inspect a project dir and decide fit + app profile. Read-only. */
134
+ detect(projectDir: string): Promise<DetectResult>;
135
+
136
+ /** Emit config artifacts (engine writes them, so --dry-run can diff). */
137
+ generateConfig(ctx: DeployContext): Promise<GeneratedConfig>;
138
+
139
+ /** Execute the deployment (the only outward-facing mutation; honors dryRun). */
140
+ deploy(ctx: DeployContext): Promise<DeployResult>;
141
+
142
+ /** Smoke-test / health-check the live target. Read-only. */
143
+ verify(ctx: DeployContext, deployment: DeployResult): Promise<VerifyResult>;
144
+
145
+ /** Best-effort rollback (caps.canRollback honest). */
146
+ rollback(ctx: DeployContext, deployment: DeployResult): Promise<DeployResult>;
147
+
148
+ /** Build the secret env map from named env vars (the deploy buildEnv). */
149
+ buildSecretEnv(store: DeploySecretStore): Record<string, string>;
150
+
151
+ /** Optional: verify CLI + creds without deploying (for doctor). */
152
+ preflight?(ctx: DeployContext): Promise<Array<{ name: string; ok: boolean; detail: string }>>;
153
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ AgentplateError,
4
+ ConfigError,
5
+ isAgentplateError,
6
+ NotFoundError,
7
+ SubprocessError,
8
+ ValidationError,
9
+ } from "./errors.ts";
10
+
11
+ describe("errors", () => {
12
+ test("AgentplateError carries code and exitCode", () => {
13
+ const err = new AgentplateError("boom");
14
+ expect(err.code).toBe("AGENTPLATE_ERROR");
15
+ expect(err.exitCode).toBe(1);
16
+ expect(err.message).toBe("boom");
17
+ expect(err.name).toBe("AgentplateError");
18
+ });
19
+
20
+ test("subclasses set stable codes and exit codes", () => {
21
+ expect(new ConfigError("x").code).toBe("CONFIG_ERROR");
22
+ expect(new ValidationError("x").code).toBe("VALIDATION_ERROR");
23
+ expect(new ValidationError("x").exitCode).toBe(2);
24
+ expect(new NotFoundError("x").exitCode).toBe(4);
25
+ });
26
+
27
+ test("subclass names reflect the class", () => {
28
+ expect(new ConfigError("x").name).toBe("ConfigError");
29
+ });
30
+
31
+ test("SubprocessError preserves the child exit code", () => {
32
+ const err = new SubprocessError("git failed", 128);
33
+ expect(err.subprocessExitCode).toBe(128);
34
+ expect(err.code).toBe("SUBPROCESS_ERROR");
35
+ });
36
+
37
+ test("isAgentplateError narrows correctly", () => {
38
+ expect(isAgentplateError(new ConfigError("x"))).toBe(true);
39
+ expect(isAgentplateError(new Error("plain"))).toBe(false);
40
+ expect(isAgentplateError("nope")).toBe(false);
41
+ });
42
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Custom error types for Agentplate.
3
+ *
4
+ * All Agentplate errors extend {@link AgentplateError}, which carries a stable string
5
+ * `code` (for JSON output / programmatic handling) and an `exitCode` used by the
6
+ * CLI entry point when terminating the process. Throw these from anywhere; the
7
+ * top-level handler in `src/index.ts` formats them consistently.
8
+ */
9
+
10
+ /** Base class for every error Agentplate raises intentionally. */
11
+ export class AgentplateError extends Error {
12
+ /** Stable, machine-readable error code (e.g. "CONFIG_ERROR"). */
13
+ readonly code: string;
14
+ /** Process exit code the CLI should use for this error. */
15
+ readonly exitCode: number;
16
+
17
+ constructor(message: string, code = "AGENTPLATE_ERROR", exitCode = 1) {
18
+ super(message);
19
+ this.name = new.target.name;
20
+ this.code = code;
21
+ this.exitCode = exitCode;
22
+ // Maintains a clean stack trace where supported.
23
+ Error.captureStackTrace?.(this, new.target);
24
+ }
25
+ }
26
+
27
+ /** Configuration is missing, malformed, or fails validation. */
28
+ export class ConfigError extends AgentplateError {
29
+ constructor(message: string) {
30
+ super(message, "CONFIG_ERROR", 1);
31
+ }
32
+ }
33
+
34
+ /** A user-supplied value (flag, argument, input) is invalid. */
35
+ export class ValidationError extends AgentplateError {
36
+ constructor(message: string) {
37
+ super(message, "VALIDATION_ERROR", 2);
38
+ }
39
+ }
40
+
41
+ /** A git worktree operation failed. */
42
+ export class WorktreeError extends AgentplateError {
43
+ constructor(message: string) {
44
+ super(message, "WORKTREE_ERROR", 1);
45
+ }
46
+ }
47
+
48
+ /** An external subprocess (git, a runtime CLI, a deploy CLI) failed. */
49
+ export class SubprocessError extends AgentplateError {
50
+ /** Exit code reported by the failed subprocess, if known. */
51
+ readonly subprocessExitCode: number | null;
52
+
53
+ constructor(message: string, subprocessExitCode: number | null = null) {
54
+ super(message, "SUBPROCESS_ERROR", 1);
55
+ this.subprocessExitCode = subprocessExitCode;
56
+ }
57
+ }
58
+
59
+ /** A requested resource (agent, session, skill, target) could not be found. */
60
+ export class NotFoundError extends AgentplateError {
61
+ constructor(message: string) {
62
+ super(message, "NOT_FOUND", 4);
63
+ }
64
+ }
65
+
66
+ /** Type guard: is the given value a AgentplateError? */
67
+ export function isAgentplateError(value: unknown): value is AgentplateError {
68
+ return value instanceof AgentplateError;
69
+ }
@@ -0,0 +1,183 @@
1
+ // Tests for the events SQLite store.
2
+ //
3
+ // We use a real in-memory SQLite database (":memory:") per test — no mocks. The
4
+ // store's only side effects are SQL writes, so an in-memory DB exercises the
5
+ // real code path (schema creation, inserts, filtered SELECTs) with no cleanup.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import type { EventStore } from "./store.ts";
9
+ import { createEventStore } from "./store.ts";
10
+
11
+ describe("createEventStore", () => {
12
+ let store: EventStore;
13
+
14
+ beforeEach(() => {
15
+ store = createEventStore(":memory:");
16
+ });
17
+
18
+ afterEach(() => {
19
+ store.close();
20
+ });
21
+
22
+ test("record assigns an id and createdAt and echoes input fields", () => {
23
+ const rec = store.record({
24
+ agentName: "builder-1",
25
+ runId: "run-abc",
26
+ type: "tool-start",
27
+ tool: "Bash",
28
+ detail: "ls -la",
29
+ });
30
+
31
+ // id should be a UUID-shaped string.
32
+ expect(typeof rec.id).toBe("string");
33
+ expect(rec.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
34
+
35
+ // createdAt should be a valid ISO-8601 timestamp.
36
+ expect(typeof rec.createdAt).toBe("string");
37
+ expect(new Date(rec.createdAt).toISOString()).toBe(rec.createdAt);
38
+
39
+ // Input fields round-trip.
40
+ expect(rec.agentName).toBe("builder-1");
41
+ expect(rec.runId).toBe("run-abc");
42
+ expect(rec.type).toBe("tool-start");
43
+ expect(rec.tool).toBe("Bash");
44
+ expect(rec.detail).toBe("ls -la");
45
+ });
46
+
47
+ test("record normalises omitted optional fields to null", () => {
48
+ const rec = store.record({ agentName: "scout-1", type: "session-start" });
49
+
50
+ expect(rec.runId).toBeNull();
51
+ expect(rec.tool).toBeNull();
52
+ expect(rec.detail).toBeNull();
53
+ });
54
+
55
+ test("record assigns unique ids across calls", () => {
56
+ const a = store.record({ agentName: "a", type: "x" });
57
+ const b = store.record({ agentName: "a", type: "x" });
58
+ expect(a.id).not.toBe(b.id);
59
+ });
60
+
61
+ test("list returns events newest first", () => {
62
+ // Insert in a known order; the third insert is the most recent.
63
+ store.record({ agentName: "a", type: "first" });
64
+ store.record({ agentName: "a", type: "second" });
65
+ store.record({ agentName: "a", type: "third" });
66
+
67
+ const events = store.list();
68
+ expect(events.length).toBe(3);
69
+ // Newest first => reverse of insertion order.
70
+ expect(events.map((e) => e.type)).toEqual(["third", "second", "first"]);
71
+
72
+ // createdAt should be monotonically non-increasing down the list.
73
+ for (let i = 1; i < events.length; i++) {
74
+ const prev = events[i - 1];
75
+ const curr = events[i];
76
+ expect(prev).toBeDefined();
77
+ expect(curr).toBeDefined();
78
+ if (prev && curr) {
79
+ expect(prev.createdAt >= curr.createdAt).toBe(true);
80
+ }
81
+ }
82
+ });
83
+
84
+ test("list filters by agentName", () => {
85
+ store.record({ agentName: "builder", type: "t" });
86
+ store.record({ agentName: "scout", type: "t" });
87
+ store.record({ agentName: "builder", type: "t" });
88
+
89
+ const builderEvents = store.list({ agentName: "builder" });
90
+ expect(builderEvents.length).toBe(2);
91
+ expect(builderEvents.every((e) => e.agentName === "builder")).toBe(true);
92
+
93
+ const scoutEvents = store.list({ agentName: "scout" });
94
+ expect(scoutEvents.length).toBe(1);
95
+ expect(scoutEvents[0]?.agentName).toBe("scout");
96
+ });
97
+
98
+ test("list filters by runId", () => {
99
+ store.record({ agentName: "a", runId: "run-1", type: "t" });
100
+ store.record({ agentName: "a", runId: "run-2", type: "t" });
101
+ store.record({ agentName: "a", runId: "run-1", type: "t" });
102
+
103
+ const run1 = store.list({ runId: "run-1" });
104
+ expect(run1.length).toBe(2);
105
+ expect(run1.every((e) => e.runId === "run-1")).toBe(true);
106
+ });
107
+
108
+ test("list filters by type", () => {
109
+ store.record({ agentName: "a", type: "tool-start" });
110
+ store.record({ agentName: "a", type: "tool-end" });
111
+ store.record({ agentName: "a", type: "tool-start" });
112
+
113
+ const starts = store.list({ type: "tool-start" });
114
+ expect(starts.length).toBe(2);
115
+ expect(starts.every((e) => e.type === "tool-start")).toBe(true);
116
+ });
117
+
118
+ test("list combines multiple filters with AND", () => {
119
+ store.record({ agentName: "builder", runId: "run-1", type: "tool-start" });
120
+ store.record({ agentName: "builder", runId: "run-1", type: "tool-end" });
121
+ store.record({ agentName: "builder", runId: "run-2", type: "tool-start" });
122
+ store.record({ agentName: "scout", runId: "run-1", type: "tool-start" });
123
+
124
+ const matched = store.list({
125
+ agentName: "builder",
126
+ runId: "run-1",
127
+ type: "tool-start",
128
+ });
129
+ expect(matched.length).toBe(1);
130
+ expect(matched[0]?.agentName).toBe("builder");
131
+ expect(matched[0]?.runId).toBe("run-1");
132
+ expect(matched[0]?.type).toBe("tool-start");
133
+ });
134
+
135
+ test("list applies a positive limit and keeps newest-first ordering", () => {
136
+ store.record({ agentName: "a", type: "1" });
137
+ store.record({ agentName: "a", type: "2" });
138
+ store.record({ agentName: "a", type: "3" });
139
+ store.record({ agentName: "a", type: "4" });
140
+
141
+ const limited = store.list({ limit: 2 });
142
+ expect(limited.length).toBe(2);
143
+ // The two newest events.
144
+ expect(limited.map((e) => e.type)).toEqual(["4", "3"]);
145
+ });
146
+
147
+ test("list with a limit larger than the row count returns all rows", () => {
148
+ store.record({ agentName: "a", type: "1" });
149
+ store.record({ agentName: "a", type: "2" });
150
+
151
+ const events = store.list({ limit: 100 });
152
+ expect(events.length).toBe(2);
153
+ });
154
+
155
+ test("list treats non-positive limit as no limit", () => {
156
+ store.record({ agentName: "a", type: "1" });
157
+ store.record({ agentName: "a", type: "2" });
158
+ store.record({ agentName: "a", type: "3" });
159
+
160
+ // 0 and negative should not silently truncate to nothing.
161
+ expect(store.list({ limit: 0 }).length).toBe(3);
162
+ expect(store.list({ limit: -5 }).length).toBe(3);
163
+ });
164
+
165
+ test("list on an empty store returns an empty array", () => {
166
+ expect(store.list()).toEqual([]);
167
+ expect(store.list({ agentName: "nobody" })).toEqual([]);
168
+ });
169
+
170
+ test("filter + limit compose correctly", () => {
171
+ // 3 builder events, 1 scout event interleaved.
172
+ store.record({ agentName: "builder", type: "a" });
173
+ store.record({ agentName: "scout", type: "a" });
174
+ store.record({ agentName: "builder", type: "b" });
175
+ store.record({ agentName: "builder", type: "c" });
176
+
177
+ const limited = store.list({ agentName: "builder", limit: 2 });
178
+ expect(limited.length).toBe(2);
179
+ expect(limited.every((e) => e.agentName === "builder")).toBe(true);
180
+ // Newest two builder events.
181
+ expect(limited.map((e) => e.type)).toEqual(["c", "b"]);
182
+ });
183
+ });
@@ -0,0 +1,201 @@
1
+ // Events SQLite store.
2
+ //
3
+ // A deliberately small, single-table event log for agent activity. Each row is
4
+ // an `EventRecord` (see src/types.ts): an append-only fact about what an agent
5
+ // did (a tool call, a state transition, etc.). The store is intentionally much
6
+ // simpler than agentplate's blueprint — no timelines, no error rollups, no
7
+ // arg filtering. It exists so observability commands (feed/trace/errors) have a
8
+ // uniform place to read from, and so multiple agents can append concurrently.
9
+ //
10
+ // WHY a single insert + one filtered SELECT: high-frequency writes from many
11
+ // worktrees dominate this workload, so we keep the schema flat and let WAL mode
12
+ // (configured by openDatabase) handle concurrency. Filtering/limiting is pushed
13
+ // into SQL so callers never load the whole log into memory.
14
+
15
+ import type { Database } from "bun:sqlite";
16
+ import { openDatabase } from "../db/sqlite.ts";
17
+ import type { EventRecord } from "../types.ts";
18
+
19
+ /** Input accepted by `record()`. `id`/`createdAt` are assigned by the store. */
20
+ export interface RecordEventInput {
21
+ agentName: string;
22
+ // Optional run grouping. `undefined` and `null` are treated identically and
23
+ // stored as SQL NULL so "no run" is a single canonical value.
24
+ runId?: string | null;
25
+ type: string;
26
+ // Optional tool name (e.g. "Bash", "Edit") and free-form detail string.
27
+ tool?: string | null;
28
+ detail?: string | null;
29
+ }
30
+
31
+ /** Filters for `list()`. All fields are optional; omitted fields are ignored. */
32
+ export interface ListEventsFilter {
33
+ agentName?: string;
34
+ runId?: string;
35
+ type?: string;
36
+ // Max rows to return. Must be a positive integer when provided; otherwise the
37
+ // whole (filtered) log is returned newest-first.
38
+ limit?: number;
39
+ }
40
+
41
+ /** Public surface returned by {@link createEventStore}. */
42
+ export interface EventStore {
43
+ record(event: RecordEventInput): EventRecord;
44
+ list(filter?: ListEventsFilter): EventRecord[];
45
+ close(): void;
46
+ }
47
+
48
+ // Raw column shape as returned by bun:sqlite. SQLite has no boolean/null typing
49
+ // at the JS boundary beyond "value or null", so we map TEXT columns to
50
+ // `string | null` and convert to the `EventRecord` shape in one place.
51
+ //
52
+ // `seq` is an internal, monotonically increasing insertion counter (autoincrement
53
+ // rowid). It is NOT part of the public `EventRecord` — it exists solely so we can
54
+ // order strictly by insertion order. We can't rely on `created_at` for ordering
55
+ // because many events can land in the same millisecond (ISO-8601 has ms
56
+ // resolution), and the public `id` is a random UUID with no temporal meaning.
57
+ interface EventRow {
58
+ seq: number;
59
+ id: string;
60
+ agent_name: string;
61
+ run_id: string | null;
62
+ type: string;
63
+ tool: string | null;
64
+ detail: string | null;
65
+ created_at: string;
66
+ }
67
+
68
+ // Translate a DB row into the shared `EventRecord` type. Centralised so the
69
+ // snake_case <-> camelCase mapping lives in exactly one spot. `seq` is dropped
70
+ // here because it is an internal ordering key, not part of the public contract.
71
+ function rowToRecord(row: EventRow): EventRecord {
72
+ return {
73
+ id: row.id,
74
+ agentName: row.agent_name,
75
+ runId: row.run_id,
76
+ type: row.type,
77
+ tool: row.tool,
78
+ detail: row.detail,
79
+ createdAt: row.created_at,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Open (creating if needed) the events store at `dbPath`.
85
+ *
86
+ * `dbPath` may be ":memory:" for tests or an absolute file path in a project's
87
+ * `.agentplate/` directory. The table is created on open so callers never need a
88
+ * separate migration step.
89
+ */
90
+ export function createEventStore(dbPath: string): EventStore {
91
+ // Guard on `events.seq` against an incompatible foreign `events` table.
92
+ const db: Database = openDatabase(dbPath, { guard: { table: "events", columns: ["seq"] } });
93
+
94
+ // Flat schema. `seq` (INTEGER PRIMARY KEY AUTOINCREMENT) is the canonical
95
+ // insertion-order key we sort by — see EventRow for why created_at/id can't
96
+ // serve that role. `id` is the public UUID, kept UNIQUE so callers can look up
97
+ // by it. `created_at` is an ISO-8601 string (lexically sortable, used only for
98
+ // display and time-range filtering, not as the primary sort key).
99
+ db.exec(`
100
+ CREATE TABLE IF NOT EXISTS events (
101
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ id TEXT NOT NULL UNIQUE,
103
+ agent_name TEXT NOT NULL,
104
+ run_id TEXT,
105
+ type TEXT NOT NULL,
106
+ tool TEXT,
107
+ detail TEXT,
108
+ created_at TEXT NOT NULL
109
+ )
110
+ `);
111
+
112
+ // Index the columns we filter by. Ordering is on `seq` (the PRIMARY KEY), which
113
+ // is already indexed, so no extra index is needed for the newest-first sort.
114
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent_name)");
115
+ db.exec("CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id)");
116
+
117
+ function record(event: RecordEventInput): EventRecord {
118
+ // Assign identity + timestamp here so callers stay declarative. UUID is the
119
+ // public id; ISO-8601 keeps timestamps human-readable. `seq` is omitted from
120
+ // the insert — SQLite autoincrements it.
121
+ const record: EventRecord = {
122
+ id: crypto.randomUUID(),
123
+ agentName: event.agentName,
124
+ // Normalise `undefined` -> `null` so the column is always an explicit value.
125
+ runId: event.runId ?? null,
126
+ type: event.type,
127
+ tool: event.tool ?? null,
128
+ detail: event.detail ?? null,
129
+ createdAt: new Date().toISOString(),
130
+ };
131
+
132
+ db.query(
133
+ `INSERT INTO events (id, agent_name, run_id, type, tool, detail, created_at)
134
+ VALUES ($id, $agent_name, $run_id, $type, $tool, $detail, $created_at)`,
135
+ ).run({
136
+ $id: record.id,
137
+ $agent_name: record.agentName,
138
+ $run_id: record.runId,
139
+ $type: record.type,
140
+ $tool: record.tool,
141
+ $detail: record.detail,
142
+ $created_at: record.createdAt,
143
+ });
144
+
145
+ // We already hold every public field, so return directly rather than reading
146
+ // the row back (the only thing the DB added is the internal `seq`).
147
+ return record;
148
+ }
149
+
150
+ function list(filter: ListEventsFilter = {}): EventRecord[] {
151
+ // Build the WHERE clause from only the provided filters. Using named
152
+ // parameters keeps the query injection-safe and lets us add clauses
153
+ // conditionally without positional bookkeeping.
154
+ const clauses: string[] = [];
155
+ // Bind values can be strings (filters) or a number (limit), so the map is
156
+ // typed accordingly. Building one params object keeps binding injection-safe.
157
+ const params: Record<string, string | number> = {};
158
+
159
+ if (filter.agentName !== undefined) {
160
+ clauses.push("agent_name = $agent_name");
161
+ params.$agent_name = filter.agentName;
162
+ }
163
+ if (filter.runId !== undefined) {
164
+ clauses.push("run_id = $run_id");
165
+ params.$run_id = filter.runId;
166
+ }
167
+ if (filter.type !== undefined) {
168
+ clauses.push("type = $type");
169
+ params.$type = filter.type;
170
+ }
171
+
172
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
173
+
174
+ // Order strictly by `seq DESC` (insertion order, newest first). This is
175
+ // deterministic even when many events share a millisecond `created_at` — see
176
+ // EventRow for the rationale.
177
+ let sql = `SELECT seq, id, agent_name, run_id, type, tool, detail, created_at
178
+ FROM events ${where}
179
+ ORDER BY seq DESC`;
180
+
181
+ // Only apply LIMIT for a positive, finite integer. Anything else (0,
182
+ // negative, NaN, undefined) is treated as "no limit" rather than silently
183
+ // returning nothing or erroring. Capturing into a local `const` lets TS
184
+ // narrow it to `number` for the bind below.
185
+ const { limit } = filter;
186
+ if (typeof limit === "number" && Number.isInteger(limit) && limit > 0) {
187
+ sql += " LIMIT $limit";
188
+ params.$limit = limit;
189
+ }
190
+
191
+ const rows = db.query(sql).all(params) as EventRow[];
192
+
193
+ return rows.map(rowToRecord);
194
+ }
195
+
196
+ function close(): void {
197
+ db.close();
198
+ }
199
+
200
+ return { record, list, close };
201
+ }