@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the deploy audit store.
|
|
3
|
+
*
|
|
4
|
+
* Uses a real temp-file SQLite database (not a mock and not `:memory:`) so the
|
|
5
|
+
* tests exercise the same WAL-mode file path deployer/verifier agents use in
|
|
6
|
+
* production. Each test gets a fresh file via beforeEach/afterEach.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { DeployAuditRow } from "../types.ts";
|
|
14
|
+
import { createDeployAudit, type DeployAudit } from "./audit.ts";
|
|
15
|
+
|
|
16
|
+
/** Minimal valid audit-row input (no id/createdAt — the store assigns those). */
|
|
17
|
+
function newRow(
|
|
18
|
+
overrides: Partial<Omit<DeployAuditRow, "id" | "createdAt">> = {},
|
|
19
|
+
): Omit<DeployAuditRow, "id" | "createdAt"> {
|
|
20
|
+
return {
|
|
21
|
+
runId: "run-1",
|
|
22
|
+
agentName: "deployer-1",
|
|
23
|
+
target: "docker-gha",
|
|
24
|
+
environment: "production",
|
|
25
|
+
action: "deploy",
|
|
26
|
+
dryRun: false,
|
|
27
|
+
gateDecision: "auto",
|
|
28
|
+
approvedBy: null,
|
|
29
|
+
status: "success",
|
|
30
|
+
deploymentId: "sha256:abc",
|
|
31
|
+
urls: [],
|
|
32
|
+
outputs: {},
|
|
33
|
+
commitSha: "deadbeef",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("deploy audit store", () => {
|
|
39
|
+
let tmpRoot: string;
|
|
40
|
+
let dbPath: string;
|
|
41
|
+
let audit: DeployAudit;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "agentplate-audit-"));
|
|
45
|
+
dbPath = join(tmpRoot, "deploys.db");
|
|
46
|
+
audit = createDeployAudit(dbPath);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
audit.close();
|
|
51
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("record assigns id + createdAt and returns the stored view", () => {
|
|
55
|
+
const stored = audit.record(newRow());
|
|
56
|
+
|
|
57
|
+
expect(stored.id).toBeTruthy();
|
|
58
|
+
// crypto.randomUUID() shape.
|
|
59
|
+
expect(stored.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
60
|
+
expect(stored.createdAt).toBeTruthy();
|
|
61
|
+
expect(new Date(stored.createdAt).toISOString()).toBe(stored.createdAt);
|
|
62
|
+
expect(stored.target).toBe("docker-gha");
|
|
63
|
+
expect(stored.status).toBe("success");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("record persists a row retrievable via list", () => {
|
|
67
|
+
const stored = audit.record(newRow());
|
|
68
|
+
|
|
69
|
+
const all = audit.list();
|
|
70
|
+
expect(all).toHaveLength(1);
|
|
71
|
+
expect(all[0]?.id).toBe(stored.id);
|
|
72
|
+
expect(all[0]?.agentName).toBe("deployer-1");
|
|
73
|
+
expect(all[0]?.action).toBe("deploy");
|
|
74
|
+
expect(all[0]?.commitSha).toBe("deadbeef");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("urls and outputs round-trip through JSON", () => {
|
|
78
|
+
const urls = ["https://app.example.com", "https://app-preview.example.com"];
|
|
79
|
+
const outputs = { region: "us-east-1", digest: "sha256:abc", count: "3" };
|
|
80
|
+
|
|
81
|
+
const stored = audit.record(newRow({ urls, outputs }));
|
|
82
|
+
// Returned view from record() already carries the structured values.
|
|
83
|
+
expect(stored.urls).toEqual(urls);
|
|
84
|
+
expect(stored.outputs).toEqual(outputs);
|
|
85
|
+
|
|
86
|
+
// And they survive the SQLite JSON round-trip on read.
|
|
87
|
+
const [read] = audit.list();
|
|
88
|
+
expect(read?.urls).toEqual(urls);
|
|
89
|
+
expect(read?.outputs).toEqual(outputs);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("empty urls/outputs default to [] and {}", () => {
|
|
93
|
+
audit.record(newRow({ urls: [], outputs: {} }));
|
|
94
|
+
|
|
95
|
+
const [read] = audit.list();
|
|
96
|
+
expect(read?.urls).toEqual([]);
|
|
97
|
+
expect(read?.outputs).toEqual({});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("dryRun and nullable fields round-trip correctly", () => {
|
|
101
|
+
audit.record(
|
|
102
|
+
newRow({
|
|
103
|
+
dryRun: true,
|
|
104
|
+
runId: null,
|
|
105
|
+
approvedBy: "alice",
|
|
106
|
+
deploymentId: null,
|
|
107
|
+
gateDecision: "approved",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const [read] = audit.list();
|
|
112
|
+
expect(read?.dryRun).toBe(true);
|
|
113
|
+
expect(read?.runId).toBeNull();
|
|
114
|
+
expect(read?.approvedBy).toBe("alice");
|
|
115
|
+
expect(read?.deploymentId).toBeNull();
|
|
116
|
+
expect(read?.gateDecision).toBe("approved");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("list returns rows newest first", () => {
|
|
120
|
+
const first = audit.record(newRow({ commitSha: "c1" }));
|
|
121
|
+
const second = audit.record(newRow({ commitSha: "c2" }));
|
|
122
|
+
const third = audit.record(newRow({ commitSha: "c3" }));
|
|
123
|
+
|
|
124
|
+
const rows = audit.list();
|
|
125
|
+
expect(rows.map((r) => r.id)).toEqual([third.id, second.id, first.id]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("list filters by target", () => {
|
|
129
|
+
audit.record(newRow({ target: "docker-gha" }));
|
|
130
|
+
audit.record(newRow({ target: "fly" }));
|
|
131
|
+
audit.record(newRow({ target: "docker-gha" }));
|
|
132
|
+
|
|
133
|
+
const rows = audit.list({ target: "docker-gha" });
|
|
134
|
+
expect(rows).toHaveLength(2);
|
|
135
|
+
expect(rows.every((r) => r.target === "docker-gha")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("list filters by environment", () => {
|
|
139
|
+
audit.record(newRow({ environment: "production" }));
|
|
140
|
+
audit.record(newRow({ environment: "staging" }));
|
|
141
|
+
audit.record(newRow({ environment: "staging" }));
|
|
142
|
+
|
|
143
|
+
const rows = audit.list({ environment: "staging" });
|
|
144
|
+
expect(rows).toHaveLength(2);
|
|
145
|
+
expect(rows.every((r) => r.environment === "staging")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("list combines target and environment filters", () => {
|
|
149
|
+
audit.record(newRow({ target: "fly", environment: "production" }));
|
|
150
|
+
audit.record(newRow({ target: "fly", environment: "staging" }));
|
|
151
|
+
audit.record(newRow({ target: "docker-gha", environment: "production" }));
|
|
152
|
+
|
|
153
|
+
const rows = audit.list({ target: "fly", environment: "production" });
|
|
154
|
+
expect(rows).toHaveLength(1);
|
|
155
|
+
expect(rows[0]?.target).toBe("fly");
|
|
156
|
+
expect(rows[0]?.environment).toBe("production");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("list honors the limit (newest first)", () => {
|
|
160
|
+
const a = audit.record(newRow({ commitSha: "a" }));
|
|
161
|
+
const b = audit.record(newRow({ commitSha: "b" }));
|
|
162
|
+
audit.record(newRow({ commitSha: "c" }));
|
|
163
|
+
|
|
164
|
+
const rows = audit.list({ limit: 2 });
|
|
165
|
+
expect(rows).toHaveLength(2);
|
|
166
|
+
// Two newest, so the first-inserted (a) is excluded.
|
|
167
|
+
expect(rows.map((r) => r.commitSha)).toEqual(["c", "b"]);
|
|
168
|
+
expect(rows.map((r) => r.id)).not.toContain(a.id);
|
|
169
|
+
expect(b.id).toBeTruthy();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("latest returns null when there is no successful deploy", () => {
|
|
173
|
+
expect(audit.latest("docker-gha", "production")).toBeNull();
|
|
174
|
+
|
|
175
|
+
// A failed deploy must not count.
|
|
176
|
+
audit.record(newRow({ status: "failed" }));
|
|
177
|
+
expect(audit.latest("docker-gha", "production")).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("latest returns the newest successful deploy", () => {
|
|
181
|
+
audit.record(newRow({ commitSha: "old", deploymentId: "sha256:old" }));
|
|
182
|
+
const newest = audit.record(newRow({ commitSha: "new", deploymentId: "sha256:new" }));
|
|
183
|
+
|
|
184
|
+
const latest = audit.latest("docker-gha", "production");
|
|
185
|
+
expect(latest?.id).toBe(newest.id);
|
|
186
|
+
expect(latest?.commitSha).toBe("new");
|
|
187
|
+
expect(latest?.deploymentId).toBe("sha256:new");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("latest ignores failed, rollback, and dry-run rows", () => {
|
|
191
|
+
const good = audit.record(newRow({ deploymentId: "sha256:good" }));
|
|
192
|
+
// Newer rows that must NOT be selected as the rollback target:
|
|
193
|
+
audit.record(newRow({ status: "failed", deploymentId: "sha256:failed" }));
|
|
194
|
+
audit.record(newRow({ action: "rollback", deploymentId: "sha256:rolledback" }));
|
|
195
|
+
audit.record(newRow({ dryRun: true, deploymentId: "sha256:dry" }));
|
|
196
|
+
|
|
197
|
+
const latest = audit.latest("docker-gha", "production");
|
|
198
|
+
expect(latest?.id).toBe(good.id);
|
|
199
|
+
expect(latest?.deploymentId).toBe("sha256:good");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("latest is scoped to the requested target and environment", () => {
|
|
203
|
+
audit.record(newRow({ target: "fly", environment: "production" }));
|
|
204
|
+
audit.record(newRow({ target: "docker-gha", environment: "staging" }));
|
|
205
|
+
const match = audit.record(newRow({ target: "docker-gha", environment: "production" }));
|
|
206
|
+
// A later success for a *different* target/env must not win.
|
|
207
|
+
audit.record(newRow({ target: "fly", environment: "staging" }));
|
|
208
|
+
|
|
209
|
+
const latest = audit.latest("docker-gha", "production");
|
|
210
|
+
expect(latest?.id).toBe(match.id);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("persists across reopen (real file, WAL mode)", () => {
|
|
214
|
+
const stored = audit.record(newRow({ urls: ["https://x.example"], outputs: { k: "v" } }));
|
|
215
|
+
audit.close();
|
|
216
|
+
|
|
217
|
+
const reopened = createDeployAudit(dbPath);
|
|
218
|
+
try {
|
|
219
|
+
const rows = reopened.list();
|
|
220
|
+
expect(rows).toHaveLength(1);
|
|
221
|
+
expect(rows[0]?.id).toBe(stored.id);
|
|
222
|
+
expect(rows[0]?.urls).toEqual(["https://x.example"]);
|
|
223
|
+
expect(rows[0]?.outputs).toEqual({ k: "v" });
|
|
224
|
+
|
|
225
|
+
const latest = reopened.latest("docker-gha", "production");
|
|
226
|
+
expect(latest?.id).toBe(stored.id);
|
|
227
|
+
} finally {
|
|
228
|
+
reopened.close();
|
|
229
|
+
// Re-open so the afterEach close() is balanced.
|
|
230
|
+
audit = createDeployAudit(dbPath);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed deploy audit store (append-only).
|
|
3
|
+
*
|
|
4
|
+
* Every deploy/rollback the pipeline executes writes one immutable row here
|
|
5
|
+
* (`deploys.db`). The audit log is the source of truth for *what shipped where*:
|
|
6
|
+
* `ap deploy status` reads it, and rollback uses {@link DeployAudit.latest} to
|
|
7
|
+
* find the last good deployment id to revert to.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Opened through {@link openDatabase} (WAL mode + busy timeout) because deployer
|
|
11
|
+
* and verifier agents may write/read concurrently.
|
|
12
|
+
* - Columns are snake_case (SQL idiom); the public {@link DeployAuditRow} shape is
|
|
13
|
+
* camelCase. {@link rowToAuditRow} is the single translation point.
|
|
14
|
+
* - `dry_run` is stored as an INTEGER (0/1) because SQLite has no boolean type.
|
|
15
|
+
* - `urls` (string[]) and `outputs` (Record<string,string>) are serialized as JSON
|
|
16
|
+
* text and round-tripped on read.
|
|
17
|
+
* - NEVER store secrets. There is deliberately no secrets column; the caller
|
|
18
|
+
* guarantees `outputs`/`urls` are already redacted (see logging/sanitizer.ts).
|
|
19
|
+
* - Greenfield schema: `CREATE TABLE IF NOT EXISTS` is the whole story, no
|
|
20
|
+
* migrations.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { openDatabase } from "../db/sqlite.ts";
|
|
24
|
+
import type { DeployAuditRow } from "../types.ts";
|
|
25
|
+
|
|
26
|
+
/** Filters accepted by {@link DeployAudit.list}. */
|
|
27
|
+
export interface DeployAuditFilter {
|
|
28
|
+
target?: string;
|
|
29
|
+
environment?: string;
|
|
30
|
+
/** Cap the number of rows returned (newest first). */
|
|
31
|
+
limit?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The deploy audit storage contract. */
|
|
35
|
+
export interface DeployAudit {
|
|
36
|
+
/** Persist one audit row; assigns id + createdAt and returns the stored view. */
|
|
37
|
+
record(row: Omit<DeployAuditRow, "id" | "createdAt">): DeployAuditRow;
|
|
38
|
+
/** Query rows with optional filters, newest first. */
|
|
39
|
+
list(filter?: DeployAuditFilter): DeployAuditRow[];
|
|
40
|
+
/** Most recent successful *deploy* for a target+environment (rollback target). */
|
|
41
|
+
latest(target: string, environment: string): DeployAuditRow | null;
|
|
42
|
+
/** Close the underlying database connection. */
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Row shape as stored in SQLite. Distinct from {@link DeployAuditRow} because
|
|
48
|
+
* columns are snake_case, `dry_run` is an integer, and `urls`/`outputs` are JSON
|
|
49
|
+
* text rather than structured values.
|
|
50
|
+
*/
|
|
51
|
+
interface AuditRow {
|
|
52
|
+
/** Monotonic insertion sequence; the deterministic ordering tiebreak. */
|
|
53
|
+
seq: number;
|
|
54
|
+
id: string;
|
|
55
|
+
run_id: string | null;
|
|
56
|
+
agent_name: string;
|
|
57
|
+
target: string;
|
|
58
|
+
environment: string;
|
|
59
|
+
action: string;
|
|
60
|
+
dry_run: number;
|
|
61
|
+
gate_decision: string;
|
|
62
|
+
approved_by: string | null;
|
|
63
|
+
status: string;
|
|
64
|
+
deployment_id: string | null;
|
|
65
|
+
urls: string;
|
|
66
|
+
outputs: string;
|
|
67
|
+
commit_sha: string;
|
|
68
|
+
created_at: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CREATE_TABLE = `
|
|
72
|
+
CREATE TABLE IF NOT EXISTS deploy_audit (
|
|
73
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
id TEXT NOT NULL UNIQUE,
|
|
75
|
+
run_id TEXT,
|
|
76
|
+
agent_name TEXT NOT NULL,
|
|
77
|
+
target TEXT NOT NULL,
|
|
78
|
+
environment TEXT NOT NULL,
|
|
79
|
+
action TEXT NOT NULL,
|
|
80
|
+
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
gate_decision TEXT NOT NULL,
|
|
82
|
+
approved_by TEXT,
|
|
83
|
+
status TEXT NOT NULL,
|
|
84
|
+
deployment_id TEXT,
|
|
85
|
+
urls TEXT NOT NULL DEFAULT '[]',
|
|
86
|
+
outputs TEXT NOT NULL DEFAULT '{}',
|
|
87
|
+
commit_sha TEXT NOT NULL,
|
|
88
|
+
created_at TEXT NOT NULL
|
|
89
|
+
)`;
|
|
90
|
+
|
|
91
|
+
// The hot read path is rollback lookup: most recent successful deploy for a
|
|
92
|
+
// (target, environment). Index those three columns up front, not per query.
|
|
93
|
+
const CREATE_INDEXES = `
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_deploy_audit_lookup
|
|
95
|
+
ON deploy_audit(target, environment, status)`;
|
|
96
|
+
|
|
97
|
+
/** Parse a JSON text column into a string array (tolerates legacy/empty values). */
|
|
98
|
+
function parseUrls(text: string): string[] {
|
|
99
|
+
const parsed: unknown = JSON.parse(text);
|
|
100
|
+
if (!Array.isArray(parsed)) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return parsed.filter((item): item is string => typeof item === "string");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Parse a JSON text column into a string→string map (tolerates legacy/empty values). */
|
|
107
|
+
function parseOutputs(text: string): Record<string, string> {
|
|
108
|
+
const parsed: unknown = JSON.parse(text);
|
|
109
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
const out: Record<string, string> = {};
|
|
113
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
out[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Translate a stored row (snake_case, int boolean, JSON text) into a {@link DeployAuditRow}. */
|
|
122
|
+
function rowToAuditRow(row: AuditRow): DeployAuditRow {
|
|
123
|
+
return {
|
|
124
|
+
id: row.id,
|
|
125
|
+
runId: row.run_id,
|
|
126
|
+
agentName: row.agent_name,
|
|
127
|
+
target: row.target,
|
|
128
|
+
environment: row.environment,
|
|
129
|
+
// The CHECK-free schema trusts callers (typed at DeployAuditRow) for valid
|
|
130
|
+
// values; narrow back to the unions here so consumers get precise types.
|
|
131
|
+
action: row.action as DeployAuditRow["action"],
|
|
132
|
+
dryRun: row.dry_run === 1,
|
|
133
|
+
gateDecision: row.gate_decision as DeployAuditRow["gateDecision"],
|
|
134
|
+
approvedBy: row.approved_by,
|
|
135
|
+
status: row.status as DeployAuditRow["status"],
|
|
136
|
+
deploymentId: row.deployment_id,
|
|
137
|
+
urls: parseUrls(row.urls),
|
|
138
|
+
outputs: parseOutputs(row.outputs),
|
|
139
|
+
commitSha: row.commit_sha,
|
|
140
|
+
createdAt: row.created_at,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Open (or create) a deploy audit store backed by the SQLite database at `dbPath`.
|
|
146
|
+
* Pass `":memory:"` for an ephemeral store (used in tests); production uses
|
|
147
|
+
* {@link deploysDbPath}.
|
|
148
|
+
*/
|
|
149
|
+
export function createDeployAudit(dbPath: string): DeployAudit {
|
|
150
|
+
const db = openDatabase(dbPath);
|
|
151
|
+
db.exec(CREATE_TABLE);
|
|
152
|
+
db.exec(CREATE_INDEXES);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
record(input: Omit<DeployAuditRow, "id" | "createdAt">): DeployAuditRow {
|
|
156
|
+
const stored: DeployAuditRow = {
|
|
157
|
+
...input,
|
|
158
|
+
id: crypto.randomUUID(),
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
db.query(
|
|
163
|
+
`INSERT INTO deploy_audit
|
|
164
|
+
(id, run_id, agent_name, target, environment, action, dry_run,
|
|
165
|
+
gate_decision, approved_by, status, deployment_id, urls, outputs,
|
|
166
|
+
commit_sha, created_at)
|
|
167
|
+
VALUES
|
|
168
|
+
($id, $runId, $agentName, $target, $environment, $action, $dryRun,
|
|
169
|
+
$gateDecision, $approvedBy, $status, $deploymentId, $urls, $outputs,
|
|
170
|
+
$commitSha, $createdAt)`,
|
|
171
|
+
).run({
|
|
172
|
+
$id: stored.id,
|
|
173
|
+
$runId: stored.runId,
|
|
174
|
+
$agentName: stored.agentName,
|
|
175
|
+
$target: stored.target,
|
|
176
|
+
$environment: stored.environment,
|
|
177
|
+
$action: stored.action,
|
|
178
|
+
// SQLite stores booleans as integers.
|
|
179
|
+
$dryRun: stored.dryRun ? 1 : 0,
|
|
180
|
+
$gateDecision: stored.gateDecision,
|
|
181
|
+
$approvedBy: stored.approvedBy,
|
|
182
|
+
$status: stored.status,
|
|
183
|
+
$deploymentId: stored.deploymentId,
|
|
184
|
+
// urls/outputs are JSON-encoded; never store secrets (caller guarantees).
|
|
185
|
+
$urls: JSON.stringify(stored.urls),
|
|
186
|
+
$outputs: JSON.stringify(stored.outputs),
|
|
187
|
+
$commitSha: stored.commitSha,
|
|
188
|
+
$createdAt: stored.createdAt,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return stored;
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
list(filter?: DeployAuditFilter): DeployAuditRow[] {
|
|
195
|
+
// Build the WHERE clause dynamically from whichever filters are set.
|
|
196
|
+
// Parameters are always bound (never interpolated) to avoid injection.
|
|
197
|
+
const conditions: string[] = [];
|
|
198
|
+
const params: Record<string, string | number> = {};
|
|
199
|
+
|
|
200
|
+
if (filter?.target !== undefined) {
|
|
201
|
+
conditions.push("target = $target");
|
|
202
|
+
params.$target = filter.target;
|
|
203
|
+
}
|
|
204
|
+
if (filter?.environment !== undefined) {
|
|
205
|
+
conditions.push("environment = $environment");
|
|
206
|
+
params.$environment = filter.environment;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
210
|
+
// Newest first. The seq tiebreak makes ordering deterministic even when
|
|
211
|
+
// many rows share a created_at (sub-millisecond inserts); UUID ids would not.
|
|
212
|
+
let sql = `SELECT * FROM deploy_audit ${where} ORDER BY created_at DESC, seq DESC`;
|
|
213
|
+
if (filter?.limit !== undefined) {
|
|
214
|
+
sql += " LIMIT $limit";
|
|
215
|
+
params.$limit = filter.limit;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const rows = db.query(sql).all(params) as AuditRow[];
|
|
219
|
+
return rows.map(rowToAuditRow);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
latest(target: string, environment: string): DeployAuditRow | null {
|
|
223
|
+
// Rollback reverts to the last good *deploy* — a prior rollback is not a
|
|
224
|
+
// state we roll forward to, so restrict action = 'deploy'. Dry runs never
|
|
225
|
+
// mutated the live target, so exclude them too.
|
|
226
|
+
const row = db
|
|
227
|
+
.query(
|
|
228
|
+
`SELECT * FROM deploy_audit
|
|
229
|
+
WHERE target = $target
|
|
230
|
+
AND environment = $environment
|
|
231
|
+
AND status = 'success'
|
|
232
|
+
AND action = 'deploy'
|
|
233
|
+
AND dry_run = 0
|
|
234
|
+
ORDER BY created_at DESC, seq DESC
|
|
235
|
+
LIMIT 1`,
|
|
236
|
+
)
|
|
237
|
+
.get({ $target: target, $environment: environment }) as AuditRow | null;
|
|
238
|
+
return row ? rowToAuditRow(row) : null;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
close(): void {
|
|
242
|
+
db.close();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|