@kirrosh/apitool 0.4.3
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/.github/workflows/ci.yml +27 -0
- package/.github/workflows/release.yml +97 -0
- package/.mcp.json +9 -0
- package/APITOOL.md +195 -0
- package/BACKLOG.md +62 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bun.lock +291 -0
- package/docs/GLOSSARY.md +182 -0
- package/docs/INDEX.md +21 -0
- package/docs/agent.md +135 -0
- package/docs/archive/APITOOL-pre-M22.md +831 -0
- package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
- package/docs/archive/M1-M2-parser-runner.md +216 -0
- package/docs/archive/M4-M7-reporter-cli.md +179 -0
- package/docs/archive/M5-M7-storage-junit.md +300 -0
- package/docs/archive/M6-webui.md +339 -0
- package/docs/ci.md +274 -0
- package/docs/generation-issues.md +67 -0
- package/generated/.env.yaml +3 -0
- package/install.ps1 +80 -0
- package/install.sh +113 -0
- package/package.json +46 -0
- package/scripts/run-mocked-tests.ts +45 -0
- package/seed-demo.ts +53 -0
- package/self-tests/auth.yaml +18 -0
- package/self-tests/collections-crud.yaml +46 -0
- package/self-tests/environments-crud.yaml +48 -0
- package/self-tests/export.yaml +32 -0
- package/self-tests/runs.yaml +16 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +126 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/coverage.ts +65 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/envs.ts +218 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +137 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +500 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +33 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +48 -0
- package/src/core/agent/tools/manage-environment.ts +40 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/index.ts +10 -0
- package/src/core/generator/openapi-reader.ts +142 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +47 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +50 -0
- package/src/core/parser/variables.ts +146 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +172 -0
- package/src/core/runner/execute-run.ts +75 -0
- package/src/core/runner/executor.ts +150 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +97 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +868 -0
- package/src/db/schema.ts +215 -0
- package/src/mcp/server.ts +47 -0
- package/src/mcp/tools/ci-init.ts +57 -0
- package/src/mcp/tools/coverage-analysis.ts +58 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-missing-tests.ts +80 -0
- package/src/mcp/tools/generate-tests-guide.ts +353 -0
- package/src/mcp/tools/manage-environment.ts +123 -0
- package/src/mcp/tools/manage-server.ts +87 -0
- package/src/mcp/tools/query-db.ts +141 -0
- package/src/mcp/tools/run-tests.ts +66 -0
- package/src/mcp/tools/save-test-suite.ts +164 -0
- package/src/mcp/tools/send-request.ts +53 -0
- package/src/mcp/tools/setup-api.ts +49 -0
- package/src/mcp/tools/validate-tests.ts +42 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +348 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +265 -0
- package/src/web/views/layout.ts +46 -0
- package/src/web/views/results.ts +209 -0
- package/tests/agent/agent-loop.test.ts +61 -0
- package/tests/agent/context-manager.test.ts +59 -0
- package/tests/agent/system-prompt.test.ts +42 -0
- package/tests/agent/tools/diagnose-failure.test.ts +85 -0
- package/tests/agent/tools/explore-api.test.ts +59 -0
- package/tests/agent/tools/manage-environment.test.ts +78 -0
- package/tests/agent/tools/query-results.test.ts +77 -0
- package/tests/agent/tools/run-tests.test.ts +89 -0
- package/tests/agent/tools/send-request.test.ts +78 -0
- package/tests/agent/tools/validate-tests.test.ts +59 -0
- package/tests/ai/ai-generator.integration.test.ts +131 -0
- package/tests/ai/llm-client.test.ts +145 -0
- package/tests/ai/output-parser.test.ts +132 -0
- package/tests/ai/prompt-builder.test.ts +67 -0
- package/tests/ai/types.test.ts +55 -0
- package/tests/cli/args.test.ts +63 -0
- package/tests/cli/chat.test.ts +38 -0
- package/tests/cli/ci-init.test.ts +112 -0
- package/tests/cli/commands.test.ts +316 -0
- package/tests/cli/coverage.test.ts +58 -0
- package/tests/cli/doctor.test.ts +39 -0
- package/tests/cli/envs.test.ts +181 -0
- package/tests/cli/init.test.ts +80 -0
- package/tests/cli/runs.test.ts +94 -0
- package/tests/cli/safe-run.test.ts +103 -0
- package/tests/cli/update.test.ts +32 -0
- package/tests/core/generator/schema-utils.test.ts +108 -0
- package/tests/core/parser/nested-assertions.test.ts +80 -0
- package/tests/core/runner/root-body-assertions.test.ts +70 -0
- package/tests/db/chat-queries.test.ts +88 -0
- package/tests/db/chat-schema.test.ts +37 -0
- package/tests/db/environments.test.ts +131 -0
- package/tests/db/queries.test.ts +409 -0
- package/tests/db/schema.test.ts +141 -0
- package/tests/fixtures/.env.yaml +3 -0
- package/tests/fixtures/auth-token-test.yaml +8 -0
- package/tests/fixtures/bail/suite-a.yaml +6 -0
- package/tests/fixtures/bail/suite-b.yaml +6 -0
- package/tests/fixtures/crud.yaml +35 -0
- package/tests/fixtures/invalid-missing-name.yaml +5 -0
- package/tests/fixtures/invalid-no-method.yaml +6 -0
- package/tests/fixtures/petstore-auth.json +295 -0
- package/tests/fixtures/petstore-simple.json +151 -0
- package/tests/fixtures/post-only.yaml +12 -0
- package/tests/fixtures/simple.yaml +6 -0
- package/tests/fixtures/valid/.env.yaml +1 -0
- package/tests/fixtures/valid/a.yaml +5 -0
- package/tests/fixtures/valid/b.yml +5 -0
- package/tests/generator/coverage-scanner.test.ts +129 -0
- package/tests/generator/data-factory.test.ts +133 -0
- package/tests/generator/openapi-reader.test.ts +131 -0
- package/tests/integration/auth-flow.test.ts +217 -0
- package/tests/mcp/coverage-analysis.test.ts +64 -0
- package/tests/mcp/explore-api-schemas.test.ts +105 -0
- package/tests/mcp/explore-api.test.ts +49 -0
- package/tests/mcp/generate-missing-tests.test.ts +69 -0
- package/tests/mcp/manage-environment.test.ts +89 -0
- package/tests/mcp/save-test-suite.test.ts +116 -0
- package/tests/mcp/send-request.test.ts +79 -0
- package/tests/mcp/setup-api.test.ts +106 -0
- package/tests/mcp/tools.test.ts +248 -0
- package/tests/parser/schema.test.ts +134 -0
- package/tests/parser/variables.test.ts +227 -0
- package/tests/parser/yaml-parser.test.ts +69 -0
- package/tests/reporter/console.test.ts +256 -0
- package/tests/reporter/json.test.ts +98 -0
- package/tests/reporter/junit.test.ts +284 -0
- package/tests/runner/assertions.test.ts +262 -0
- package/tests/runner/executor.test.ts +310 -0
- package/tests/runner/http-client.test.ts +138 -0
- package/tests/web/routes.test.ts +160 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { getDb } from "./schema.ts";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import type { StepResult, TestRunResult } from "../core/runner/types.ts";
|
|
4
|
+
|
|
5
|
+
// ──────────────────────────────────────────────
|
|
6
|
+
// Path normalization
|
|
7
|
+
// ──────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function normalizePath(p: string): string {
|
|
10
|
+
return resolve(p).replace(/\\/g, "/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ──────────────────────────────────────────────
|
|
14
|
+
// Types
|
|
15
|
+
// ──────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface CreateRunOpts {
|
|
18
|
+
started_at: string;
|
|
19
|
+
environment?: string;
|
|
20
|
+
trigger?: string;
|
|
21
|
+
commit_sha?: string;
|
|
22
|
+
branch?: string;
|
|
23
|
+
collection_id?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunRecord {
|
|
27
|
+
id: number;
|
|
28
|
+
started_at: string;
|
|
29
|
+
finished_at: string | null;
|
|
30
|
+
total: number;
|
|
31
|
+
passed: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
skipped: number;
|
|
34
|
+
trigger: string;
|
|
35
|
+
commit_sha: string | null;
|
|
36
|
+
branch: string | null;
|
|
37
|
+
environment: string | null;
|
|
38
|
+
duration_ms: number | null;
|
|
39
|
+
collection_id: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunSummary {
|
|
43
|
+
id: number;
|
|
44
|
+
started_at: string;
|
|
45
|
+
finished_at: string | null;
|
|
46
|
+
total: number;
|
|
47
|
+
passed: number;
|
|
48
|
+
failed: number;
|
|
49
|
+
skipped: number;
|
|
50
|
+
environment: string | null;
|
|
51
|
+
duration_ms: number | null;
|
|
52
|
+
collection_id: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ──────────────────────────────────────────────
|
|
56
|
+
// Collection types
|
|
57
|
+
// ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface CollectionRecord {
|
|
60
|
+
id: number;
|
|
61
|
+
name: string;
|
|
62
|
+
base_dir: string | null;
|
|
63
|
+
test_path: string;
|
|
64
|
+
openapi_spec: string | null;
|
|
65
|
+
created_at: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CollectionSummary {
|
|
69
|
+
id: number;
|
|
70
|
+
name: string;
|
|
71
|
+
base_dir: string | null;
|
|
72
|
+
test_path: string;
|
|
73
|
+
openapi_spec: string | null;
|
|
74
|
+
created_at: string;
|
|
75
|
+
total_runs: number;
|
|
76
|
+
pass_rate: number;
|
|
77
|
+
last_run_at: string | null;
|
|
78
|
+
last_run_passed: number;
|
|
79
|
+
last_run_failed: number;
|
|
80
|
+
last_run_total: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CreateCollectionOpts {
|
|
84
|
+
name: string;
|
|
85
|
+
base_dir?: string;
|
|
86
|
+
test_path: string;
|
|
87
|
+
openapi_spec?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StoredStepResult {
|
|
91
|
+
id: number;
|
|
92
|
+
run_id: number;
|
|
93
|
+
suite_name: string;
|
|
94
|
+
test_name: string;
|
|
95
|
+
status: string;
|
|
96
|
+
duration_ms: number;
|
|
97
|
+
request_method: string | null;
|
|
98
|
+
request_url: string | null;
|
|
99
|
+
request_body: string | null;
|
|
100
|
+
response_status: number | null;
|
|
101
|
+
response_body: string | null;
|
|
102
|
+
response_headers: string | null;
|
|
103
|
+
error_message: string | null;
|
|
104
|
+
assertions: import("../core/runner/types.ts").AssertionResult[];
|
|
105
|
+
captures: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ──────────────────────────────────────────────
|
|
109
|
+
// Runs
|
|
110
|
+
// ──────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function createRun(opts: CreateRunOpts): number {
|
|
113
|
+
const db = getDb();
|
|
114
|
+
const stmt = db.prepare(`
|
|
115
|
+
INSERT INTO runs (started_at, environment, trigger, commit_sha, branch, collection_id)
|
|
116
|
+
VALUES ($started_at, $environment, $trigger, $commit_sha, $branch, $collection_id)
|
|
117
|
+
`);
|
|
118
|
+
const result = stmt.run({
|
|
119
|
+
$started_at: opts.started_at,
|
|
120
|
+
$environment: opts.environment ?? null,
|
|
121
|
+
$trigger: opts.trigger ?? "manual",
|
|
122
|
+
$commit_sha: opts.commit_sha ?? null,
|
|
123
|
+
$branch: opts.branch ?? null,
|
|
124
|
+
$collection_id: opts.collection_id ?? null,
|
|
125
|
+
});
|
|
126
|
+
return Number(result.lastInsertRowid);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function finalizeRun(runId: number, results: TestRunResult[]): void {
|
|
130
|
+
const db = getDb();
|
|
131
|
+
|
|
132
|
+
const total = results.reduce((s, r) => s + r.total, 0);
|
|
133
|
+
const passed = results.reduce((s, r) => s + r.passed, 0);
|
|
134
|
+
const failed = results.reduce((s, r) => s + r.failed, 0);
|
|
135
|
+
const skipped = results.reduce((s, r) => s + r.skipped, 0);
|
|
136
|
+
|
|
137
|
+
const started = results[0]?.started_at ?? new Date().toISOString();
|
|
138
|
+
const finished = results[results.length - 1]?.finished_at ?? new Date().toISOString();
|
|
139
|
+
const durationMs = new Date(finished).getTime() - new Date(started).getTime();
|
|
140
|
+
|
|
141
|
+
db.prepare(`
|
|
142
|
+
UPDATE runs
|
|
143
|
+
SET finished_at = $finished_at,
|
|
144
|
+
total = $total,
|
|
145
|
+
passed = $passed,
|
|
146
|
+
failed = $failed,
|
|
147
|
+
skipped = $skipped,
|
|
148
|
+
duration_ms = $duration_ms
|
|
149
|
+
WHERE id = $id
|
|
150
|
+
`).run({
|
|
151
|
+
$finished_at: finished,
|
|
152
|
+
$total: total,
|
|
153
|
+
$passed: passed,
|
|
154
|
+
$failed: failed,
|
|
155
|
+
$skipped: skipped,
|
|
156
|
+
$duration_ms: durationMs,
|
|
157
|
+
$id: runId,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function getRunById(runId: number): RunRecord | null {
|
|
162
|
+
const db = getDb();
|
|
163
|
+
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as RunRecord | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RunFilters {
|
|
167
|
+
status?: string;
|
|
168
|
+
environment?: string;
|
|
169
|
+
date_from?: string;
|
|
170
|
+
date_to?: string;
|
|
171
|
+
test_name?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildRunFilterSQL(filters: RunFilters): { where: string; params: unknown[] } {
|
|
175
|
+
const clauses: string[] = [];
|
|
176
|
+
const params: unknown[] = [];
|
|
177
|
+
|
|
178
|
+
if (filters.status === "has_failures") {
|
|
179
|
+
clauses.push("r.failed > 0");
|
|
180
|
+
} else if (filters.status === "all_passed") {
|
|
181
|
+
clauses.push("r.failed = 0 AND r.total > 0");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (filters.environment) {
|
|
185
|
+
clauses.push("r.environment = ?");
|
|
186
|
+
params.push(filters.environment);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (filters.date_from) {
|
|
190
|
+
clauses.push("r.started_at >= ?");
|
|
191
|
+
params.push(filters.date_from);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (filters.date_to) {
|
|
195
|
+
clauses.push("r.started_at <= ?");
|
|
196
|
+
params.push(filters.date_to + "T23:59:59");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (filters.test_name) {
|
|
200
|
+
clauses.push("r.id IN (SELECT DISTINCT run_id FROM results WHERE test_name LIKE ?)");
|
|
201
|
+
params.push(`%${filters.test_name}%`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const where = clauses.length > 0 ? "WHERE " + clauses.join(" AND ") : "";
|
|
205
|
+
return { where, params };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function listRuns(limit = 20, offset = 0, filters?: RunFilters): RunSummary[] {
|
|
209
|
+
const db = getDb();
|
|
210
|
+
if (filters && Object.values(filters).some(Boolean)) {
|
|
211
|
+
const { where, params } = buildRunFilterSQL(filters);
|
|
212
|
+
return db.query(`
|
|
213
|
+
SELECT r.id, r.started_at, r.finished_at, r.total, r.passed, r.failed, r.skipped, r.environment, r.duration_ms, r.collection_id
|
|
214
|
+
FROM runs r
|
|
215
|
+
${where}
|
|
216
|
+
ORDER BY r.started_at DESC
|
|
217
|
+
LIMIT ? OFFSET ?
|
|
218
|
+
`).all(...(params as (string | number)[]), limit, offset) as RunSummary[];
|
|
219
|
+
}
|
|
220
|
+
return db.query(`
|
|
221
|
+
SELECT id, started_at, finished_at, total, passed, failed, skipped, environment, duration_ms, collection_id
|
|
222
|
+
FROM runs
|
|
223
|
+
ORDER BY started_at DESC
|
|
224
|
+
LIMIT ? OFFSET ?
|
|
225
|
+
`).all(limit, offset) as RunSummary[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function deleteRun(runId: number): boolean {
|
|
229
|
+
const db = getDb();
|
|
230
|
+
// results are cascade-deleted via FK; but SQLite FK delete cascade requires explicit config
|
|
231
|
+
db.prepare("DELETE FROM results WHERE run_id = ?").run(runId);
|
|
232
|
+
const result = db.prepare("DELETE FROM runs WHERE id = ?").run(runId);
|
|
233
|
+
return result.changes > 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ──────────────────────────────────────────────
|
|
237
|
+
// Results (steps)
|
|
238
|
+
// ──────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export function saveResults(runId: number, suiteResults: TestRunResult[]): void {
|
|
241
|
+
const db = getDb();
|
|
242
|
+
|
|
243
|
+
const stmt = db.prepare(`
|
|
244
|
+
INSERT INTO results
|
|
245
|
+
(run_id, suite_name, test_name, status, duration_ms,
|
|
246
|
+
request_method, request_url, request_body,
|
|
247
|
+
response_status, response_body, response_headers, error_message, assertions, captures)
|
|
248
|
+
VALUES
|
|
249
|
+
($run_id, $suite_name, $test_name, $status, $duration_ms,
|
|
250
|
+
$request_method, $request_url, $request_body,
|
|
251
|
+
$response_status, $response_body, $response_headers, $error_message, $assertions, $captures)
|
|
252
|
+
`);
|
|
253
|
+
|
|
254
|
+
db.transaction(() => {
|
|
255
|
+
for (const suite of suiteResults) {
|
|
256
|
+
for (const step of suite.steps) {
|
|
257
|
+
const keepBody = step.status === "fail" || step.status === "error";
|
|
258
|
+
stmt.run({
|
|
259
|
+
$run_id: runId,
|
|
260
|
+
$suite_name: suite.suite_name,
|
|
261
|
+
$test_name: step.name,
|
|
262
|
+
$status: step.status,
|
|
263
|
+
$duration_ms: step.duration_ms,
|
|
264
|
+
$request_method: step.request.method,
|
|
265
|
+
$request_url: step.request.url,
|
|
266
|
+
$request_body: step.request.body ?? null,
|
|
267
|
+
$response_status: step.response?.status ?? null,
|
|
268
|
+
$response_body: keepBody ? (step.response?.body ?? null) : null,
|
|
269
|
+
$response_headers: keepBody && step.response?.headers
|
|
270
|
+
? JSON.stringify(step.response.headers)
|
|
271
|
+
: null,
|
|
272
|
+
$error_message: step.error ?? null,
|
|
273
|
+
$assertions: step.assertions.length > 0 ? JSON.stringify(step.assertions) : null,
|
|
274
|
+
$captures: Object.keys(step.captures).length > 0 ? JSON.stringify(step.captures) : null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function getResultsByRunId(runId: number): StoredStepResult[] {
|
|
282
|
+
const db = getDb();
|
|
283
|
+
const rows = db.query("SELECT * FROM results WHERE run_id = ? ORDER BY id").all(runId) as Array<
|
|
284
|
+
Omit<StoredStepResult, "assertions" | "captures"> & { assertions: string | null; captures: string | null }
|
|
285
|
+
>;
|
|
286
|
+
return rows.map((row) => ({
|
|
287
|
+
...row,
|
|
288
|
+
assertions: row.assertions ? JSON.parse(row.assertions) : [],
|
|
289
|
+
captures: row.captures ? JSON.parse(row.captures) : {},
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ──────────────────────────────────────────────
|
|
294
|
+
// Environments
|
|
295
|
+
// ──────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
export function upsertEnvironment(name: string, vars: Record<string, string>, collectionId?: number | null): void {
|
|
298
|
+
const db = getDb();
|
|
299
|
+
const cid = collectionId ?? null;
|
|
300
|
+
// Check if exists first (unique index is composite)
|
|
301
|
+
const existing = cid === null
|
|
302
|
+
? db.query("SELECT id FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { id: number } | null
|
|
303
|
+
: db.query("SELECT id FROM environments WHERE name = ? AND collection_id = ?").get(name, cid) as { id: number } | null;
|
|
304
|
+
|
|
305
|
+
if (existing) {
|
|
306
|
+
db.prepare("UPDATE environments SET variables = ? WHERE id = ?").run(JSON.stringify(vars), existing.id);
|
|
307
|
+
} else {
|
|
308
|
+
db.prepare("INSERT INTO environments (name, collection_id, variables) VALUES (?, ?, ?)").run(name, cid, JSON.stringify(vars));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function getEnvironment(name: string, collectionId?: number): Record<string, string> | null {
|
|
313
|
+
const db = getDb();
|
|
314
|
+
if (collectionId !== undefined) {
|
|
315
|
+
// Try scoped first, then global
|
|
316
|
+
const scoped = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id = ?").get(name, collectionId) as { variables: string } | null;
|
|
317
|
+
if (scoped) return JSON.parse(scoped.variables);
|
|
318
|
+
}
|
|
319
|
+
const global = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { variables: string } | null;
|
|
320
|
+
return global ? JSON.parse(global.variables) : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function resolveEnvironment(name: string, collectionId?: number): Record<string, string> | null {
|
|
324
|
+
const db = getDb();
|
|
325
|
+
// Start with global as base
|
|
326
|
+
const globalRow = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { variables: string } | null;
|
|
327
|
+
const globalVars = globalRow ? JSON.parse(globalRow.variables) as Record<string, string> : null;
|
|
328
|
+
|
|
329
|
+
if (collectionId === undefined) return globalVars;
|
|
330
|
+
|
|
331
|
+
// Scoped overrides
|
|
332
|
+
const scopedRow = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id = ?").get(name, collectionId) as { variables: string } | null;
|
|
333
|
+
if (!scopedRow) return globalVars;
|
|
334
|
+
|
|
335
|
+
const scopedVars = JSON.parse(scopedRow.variables) as Record<string, string>;
|
|
336
|
+
if (!globalVars) return scopedVars;
|
|
337
|
+
|
|
338
|
+
// Merge: global base + scoped overrides
|
|
339
|
+
return { ...globalVars, ...scopedVars };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface EnvironmentRecord {
|
|
343
|
+
id: number;
|
|
344
|
+
name: string;
|
|
345
|
+
collection_id: number | null;
|
|
346
|
+
variables: Record<string, string>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function listEnvironments(collectionId?: number): string[] {
|
|
350
|
+
const db = getDb();
|
|
351
|
+
if (collectionId !== undefined) {
|
|
352
|
+
const rows = db.query("SELECT DISTINCT name FROM environments WHERE collection_id = ? OR collection_id IS NULL ORDER BY name").all(collectionId) as { name: string }[];
|
|
353
|
+
return rows.map((r) => r.name);
|
|
354
|
+
}
|
|
355
|
+
const rows = db.query("SELECT name FROM environments ORDER BY name").all() as { name: string }[];
|
|
356
|
+
return rows.map((r) => r.name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function listEnvironmentRecords(collectionId?: number | null): EnvironmentRecord[] {
|
|
360
|
+
const db = getDb();
|
|
361
|
+
let rows: { id: number; name: string; collection_id: number | null; variables: string }[];
|
|
362
|
+
if (collectionId !== undefined) {
|
|
363
|
+
if (collectionId === null) {
|
|
364
|
+
rows = db.query("SELECT id, name, collection_id, variables FROM environments WHERE collection_id IS NULL ORDER BY name").all() as typeof rows;
|
|
365
|
+
} else {
|
|
366
|
+
rows = db.query("SELECT id, name, collection_id, variables FROM environments WHERE collection_id = ? OR collection_id IS NULL ORDER BY collection_id IS NULL, name").all(collectionId) as typeof rows;
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
rows = db.query("SELECT id, name, collection_id, variables FROM environments ORDER BY name").all() as typeof rows;
|
|
370
|
+
}
|
|
371
|
+
return rows.map((r) => ({ id: r.id, name: r.name, collection_id: r.collection_id, variables: JSON.parse(r.variables) }));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function getEnvironmentById(id: number): EnvironmentRecord | null {
|
|
375
|
+
const db = getDb();
|
|
376
|
+
const row = db.query("SELECT id, name, collection_id, variables FROM environments WHERE id = ?").get(id) as { id: number; name: string; collection_id: number | null; variables: string } | null;
|
|
377
|
+
if (!row) return null;
|
|
378
|
+
return { id: row.id, name: row.name, collection_id: row.collection_id, variables: JSON.parse(row.variables) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function deleteEnvironment(id: number): boolean {
|
|
382
|
+
const db = getDb();
|
|
383
|
+
const result = db.prepare("DELETE FROM environments WHERE id = ?").run(id);
|
|
384
|
+
return result.changes > 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ──────────────────────────────────────────────
|
|
388
|
+
// Dashboard metrics
|
|
389
|
+
// ──────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
export interface DashboardStats {
|
|
392
|
+
totalRuns: number;
|
|
393
|
+
totalTests: number;
|
|
394
|
+
overallPassRate: number;
|
|
395
|
+
avgDuration: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function getDashboardStats(): DashboardStats {
|
|
399
|
+
const db = getDb();
|
|
400
|
+
const row = db.query(`
|
|
401
|
+
SELECT
|
|
402
|
+
COUNT(*) AS totalRuns,
|
|
403
|
+
COALESCE(SUM(total), 0) AS totalTests,
|
|
404
|
+
CASE WHEN SUM(total) > 0
|
|
405
|
+
THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
|
|
406
|
+
ELSE 0 END AS overallPassRate,
|
|
407
|
+
COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
|
|
408
|
+
FROM runs
|
|
409
|
+
WHERE finished_at IS NOT NULL
|
|
410
|
+
`).get() as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
|
|
411
|
+
return row;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface PassRateTrendPoint {
|
|
415
|
+
run_id: number;
|
|
416
|
+
started_at: string;
|
|
417
|
+
pass_rate: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function getPassRateTrend(limit = 30): PassRateTrendPoint[] {
|
|
421
|
+
const db = getDb();
|
|
422
|
+
return db.query(`
|
|
423
|
+
SELECT id AS run_id, started_at,
|
|
424
|
+
CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
|
|
425
|
+
FROM runs
|
|
426
|
+
WHERE finished_at IS NOT NULL
|
|
427
|
+
ORDER BY started_at DESC
|
|
428
|
+
LIMIT ?
|
|
429
|
+
`).all(limit) as PassRateTrendPoint[];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface SlowestTest {
|
|
433
|
+
suite_name: string;
|
|
434
|
+
test_name: string;
|
|
435
|
+
avg_duration: number;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function getSlowestTests(limit = 5): SlowestTest[] {
|
|
439
|
+
const db = getDb();
|
|
440
|
+
return db.query(`
|
|
441
|
+
SELECT suite_name, test_name, ROUND(AVG(duration_ms), 0) AS avg_duration
|
|
442
|
+
FROM results
|
|
443
|
+
GROUP BY suite_name, test_name
|
|
444
|
+
ORDER BY avg_duration DESC
|
|
445
|
+
LIMIT ?
|
|
446
|
+
`).all(limit) as SlowestTest[];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface FlakyTest {
|
|
450
|
+
suite_name: string;
|
|
451
|
+
test_name: string;
|
|
452
|
+
distinct_statuses: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function getFlakyTests(runsBack = 20, limit = 5): FlakyTest[] {
|
|
456
|
+
const db = getDb();
|
|
457
|
+
return db.query(`
|
|
458
|
+
SELECT r.suite_name, r.test_name, COUNT(DISTINCT r.status) AS distinct_statuses
|
|
459
|
+
FROM results r
|
|
460
|
+
INNER JOIN (SELECT id FROM runs ORDER BY started_at DESC LIMIT ?) recent ON r.run_id = recent.id
|
|
461
|
+
GROUP BY r.suite_name, r.test_name
|
|
462
|
+
HAVING COUNT(DISTINCT r.status) > 1
|
|
463
|
+
ORDER BY distinct_statuses DESC
|
|
464
|
+
LIMIT ?
|
|
465
|
+
`).all(runsBack, limit) as FlakyTest[];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function countRuns(filters?: RunFilters): number {
|
|
469
|
+
const db = getDb();
|
|
470
|
+
if (filters && Object.values(filters).some(Boolean)) {
|
|
471
|
+
const { where, params } = buildRunFilterSQL(filters);
|
|
472
|
+
const row = db.query(`SELECT COUNT(*) AS cnt FROM runs r ${where}`).get(...(params as (string | number)[])) as { cnt: number };
|
|
473
|
+
return row.cnt;
|
|
474
|
+
}
|
|
475
|
+
const row = db.query("SELECT COUNT(*) AS cnt FROM runs").get() as { cnt: number };
|
|
476
|
+
return row.cnt;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function getDistinctEnvironments(): string[] {
|
|
480
|
+
const db = getDb();
|
|
481
|
+
const rows = db.query("SELECT DISTINCT environment FROM runs WHERE environment IS NOT NULL ORDER BY environment").all() as { environment: string }[];
|
|
482
|
+
return rows.map((r) => r.environment);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ──────────────────────────────────────────────
|
|
486
|
+
// Collections
|
|
487
|
+
// ──────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
export function createCollection(opts: CreateCollectionOpts): number {
|
|
490
|
+
const db = getDb();
|
|
491
|
+
const stmt = db.prepare(`
|
|
492
|
+
INSERT INTO collections (name, base_dir, test_path, openapi_spec)
|
|
493
|
+
VALUES ($name, $base_dir, $test_path, $openapi_spec)
|
|
494
|
+
`);
|
|
495
|
+
const result = stmt.run({
|
|
496
|
+
$name: opts.name,
|
|
497
|
+
$base_dir: opts.base_dir ?? null,
|
|
498
|
+
$test_path: opts.test_path,
|
|
499
|
+
$openapi_spec: opts.openapi_spec ?? null,
|
|
500
|
+
});
|
|
501
|
+
return Number(result.lastInsertRowid);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function getCollectionById(id: number): CollectionRecord | null {
|
|
505
|
+
const db = getDb();
|
|
506
|
+
return db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function listCollections(): CollectionSummary[] {
|
|
510
|
+
const db = getDb();
|
|
511
|
+
return db.query(`
|
|
512
|
+
SELECT
|
|
513
|
+
c.id, c.name, c.base_dir, c.test_path, c.openapi_spec, c.created_at,
|
|
514
|
+
COUNT(r.id) AS total_runs,
|
|
515
|
+
CASE WHEN SUM(r.total) > 0
|
|
516
|
+
THEN ROUND(SUM(r.passed) * 100.0 / SUM(r.total), 1)
|
|
517
|
+
ELSE 0 END AS pass_rate,
|
|
518
|
+
MAX(r.started_at) AS last_run_at,
|
|
519
|
+
COALESCE((SELECT passed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_passed,
|
|
520
|
+
COALESCE((SELECT failed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_failed,
|
|
521
|
+
COALESCE((SELECT total FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_total
|
|
522
|
+
FROM collections c
|
|
523
|
+
LEFT JOIN runs r ON r.collection_id = c.id AND r.finished_at IS NOT NULL
|
|
524
|
+
GROUP BY c.id
|
|
525
|
+
ORDER BY c.name
|
|
526
|
+
`).all() as CollectionSummary[];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function updateCollection(id: number, opts: Partial<CreateCollectionOpts>): boolean {
|
|
530
|
+
const db = getDb();
|
|
531
|
+
const sets: string[] = [];
|
|
532
|
+
const params: Record<string, any> = { $id: id };
|
|
533
|
+
|
|
534
|
+
if (opts.name !== undefined) { sets.push("name = $name"); params.$name = opts.name; }
|
|
535
|
+
if (opts.base_dir !== undefined) { sets.push("base_dir = $base_dir"); params.$base_dir = opts.base_dir; }
|
|
536
|
+
if (opts.test_path !== undefined) { sets.push("test_path = $test_path"); params.$test_path = opts.test_path; }
|
|
537
|
+
if (opts.openapi_spec !== undefined) { sets.push("openapi_spec = $openapi_spec"); params.$openapi_spec = opts.openapi_spec; }
|
|
538
|
+
|
|
539
|
+
if (sets.length === 0) return false;
|
|
540
|
+
|
|
541
|
+
const result = db.prepare(`UPDATE collections SET ${sets.join(", ")} WHERE id = $id`).run(params);
|
|
542
|
+
return result.changes > 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function deleteCollection(id: number, deleteRuns = false): boolean {
|
|
546
|
+
const db = getDb();
|
|
547
|
+
if (deleteRuns) {
|
|
548
|
+
const runIds = db.query("SELECT id FROM runs WHERE collection_id = ?").all(id) as { id: number }[];
|
|
549
|
+
for (const row of runIds) {
|
|
550
|
+
db.prepare("DELETE FROM results WHERE run_id = ?").run(row.id);
|
|
551
|
+
}
|
|
552
|
+
db.prepare("DELETE FROM runs WHERE collection_id = ?").run(id);
|
|
553
|
+
} else {
|
|
554
|
+
db.prepare("UPDATE runs SET collection_id = NULL WHERE collection_id = ?").run(id);
|
|
555
|
+
}
|
|
556
|
+
const result = db.prepare("DELETE FROM collections WHERE id = ?").run(id);
|
|
557
|
+
return result.changes > 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function findCollectionByTestPath(path: string): CollectionRecord | null {
|
|
561
|
+
const db = getDb();
|
|
562
|
+
const normalized = normalizePath(path);
|
|
563
|
+
return db.query("SELECT * FROM collections WHERE test_path = ?").get(normalized) as CollectionRecord | null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function findCollectionByNameOrId(nameOrId: string): CollectionRecord | null {
|
|
567
|
+
const db = getDb();
|
|
568
|
+
// Try as numeric ID first
|
|
569
|
+
const id = parseInt(nameOrId, 10);
|
|
570
|
+
if (!isNaN(id)) {
|
|
571
|
+
const byId = db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
|
|
572
|
+
if (byId) return byId;
|
|
573
|
+
}
|
|
574
|
+
// Then by name (case-insensitive)
|
|
575
|
+
return db.query("SELECT * FROM collections WHERE lower(name) = lower(?)").get(nameOrId) as CollectionRecord | null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function findCollectionBySpec(spec: string): CollectionRecord | null {
|
|
579
|
+
const db = getDb();
|
|
580
|
+
return db.query("SELECT * FROM collections WHERE openapi_spec = ?").get(spec) as CollectionRecord | null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function listRunsByCollection(collectionId: number, limit = 20, offset = 0): RunSummary[] {
|
|
584
|
+
const db = getDb();
|
|
585
|
+
return db.query(`
|
|
586
|
+
SELECT id, started_at, finished_at, total, passed, failed, skipped, environment, duration_ms, collection_id
|
|
587
|
+
FROM runs
|
|
588
|
+
WHERE collection_id = ?
|
|
589
|
+
ORDER BY started_at DESC
|
|
590
|
+
LIMIT ? OFFSET ?
|
|
591
|
+
`).all(collectionId, limit, offset) as RunSummary[];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function getCollectionPassRateTrend(collectionId: number, limit = 30): PassRateTrendPoint[] {
|
|
595
|
+
const db = getDb();
|
|
596
|
+
return db.query(`
|
|
597
|
+
SELECT id AS run_id, started_at,
|
|
598
|
+
CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
|
|
599
|
+
FROM runs
|
|
600
|
+
WHERE collection_id = ? AND finished_at IS NOT NULL
|
|
601
|
+
ORDER BY started_at DESC
|
|
602
|
+
LIMIT ?
|
|
603
|
+
`).all(collectionId, limit) as PassRateTrendPoint[];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function countRunsByCollection(collectionId: number): number {
|
|
607
|
+
const db = getDb();
|
|
608
|
+
const row = db.query("SELECT COUNT(*) AS cnt FROM runs WHERE collection_id = ?").get(collectionId) as { cnt: number };
|
|
609
|
+
return row.cnt;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function getCollectionStats(collectionId: number): DashboardStats {
|
|
613
|
+
const db = getDb();
|
|
614
|
+
const row = db.query(`
|
|
615
|
+
SELECT
|
|
616
|
+
COUNT(*) AS totalRuns,
|
|
617
|
+
COALESCE(SUM(total), 0) AS totalTests,
|
|
618
|
+
CASE WHEN SUM(total) > 0
|
|
619
|
+
THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
|
|
620
|
+
ELSE 0 END AS overallPassRate,
|
|
621
|
+
COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
|
|
622
|
+
FROM runs
|
|
623
|
+
WHERE collection_id = ? AND finished_at IS NOT NULL
|
|
624
|
+
`).get(collectionId) as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
|
|
625
|
+
return row;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function linkRunToCollection(runId: number, collectionId: number): void {
|
|
629
|
+
const db = getDb();
|
|
630
|
+
db.prepare("UPDATE runs SET collection_id = ? WHERE id = ?").run(collectionId, runId);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ──────────────────────────────────────────────
|
|
634
|
+
// AI Generations
|
|
635
|
+
// ──────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
export interface AIGenerationRecord {
|
|
638
|
+
id: number;
|
|
639
|
+
collection_id: number | null;
|
|
640
|
+
prompt: string;
|
|
641
|
+
model: string;
|
|
642
|
+
provider: string;
|
|
643
|
+
generated_yaml: string | null;
|
|
644
|
+
output_path: string | null;
|
|
645
|
+
status: string;
|
|
646
|
+
error_message: string | null;
|
|
647
|
+
prompt_tokens: number | null;
|
|
648
|
+
completion_tokens: number | null;
|
|
649
|
+
duration_ms: number | null;
|
|
650
|
+
created_at: string;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export interface SaveAIGenerationOpts {
|
|
654
|
+
collection_id?: number;
|
|
655
|
+
prompt: string;
|
|
656
|
+
model: string;
|
|
657
|
+
provider: string;
|
|
658
|
+
generated_yaml?: string;
|
|
659
|
+
output_path?: string;
|
|
660
|
+
status: string;
|
|
661
|
+
error_message?: string;
|
|
662
|
+
prompt_tokens?: number;
|
|
663
|
+
completion_tokens?: number;
|
|
664
|
+
duration_ms?: number;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function saveAIGeneration(opts: SaveAIGenerationOpts): number {
|
|
668
|
+
const db = getDb();
|
|
669
|
+
const result = db.prepare(`
|
|
670
|
+
INSERT INTO ai_generations
|
|
671
|
+
(collection_id, prompt, model, provider, generated_yaml, output_path,
|
|
672
|
+
status, error_message, prompt_tokens, completion_tokens, duration_ms)
|
|
673
|
+
VALUES ($collection_id, $prompt, $model, $provider, $generated_yaml, $output_path,
|
|
674
|
+
$status, $error_message, $prompt_tokens, $completion_tokens, $duration_ms)
|
|
675
|
+
`).run({
|
|
676
|
+
$collection_id: opts.collection_id ?? null,
|
|
677
|
+
$prompt: opts.prompt,
|
|
678
|
+
$model: opts.model,
|
|
679
|
+
$provider: opts.provider,
|
|
680
|
+
$generated_yaml: opts.generated_yaml ?? null,
|
|
681
|
+
$output_path: opts.output_path ?? null,
|
|
682
|
+
$status: opts.status,
|
|
683
|
+
$error_message: opts.error_message ?? null,
|
|
684
|
+
$prompt_tokens: opts.prompt_tokens ?? null,
|
|
685
|
+
$completion_tokens: opts.completion_tokens ?? null,
|
|
686
|
+
$duration_ms: opts.duration_ms ?? null,
|
|
687
|
+
});
|
|
688
|
+
return Number(result.lastInsertRowid);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function listAIGenerations(collectionId: number, limit = 10): AIGenerationRecord[] {
|
|
692
|
+
const db = getDb();
|
|
693
|
+
return db.query(`
|
|
694
|
+
SELECT * FROM ai_generations
|
|
695
|
+
WHERE collection_id = ?
|
|
696
|
+
ORDER BY created_at DESC
|
|
697
|
+
LIMIT ?
|
|
698
|
+
`).all(collectionId, limit) as AIGenerationRecord[];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function getAIGeneration(id: number): AIGenerationRecord | null {
|
|
702
|
+
const db = getDb();
|
|
703
|
+
return db.query("SELECT * FROM ai_generations WHERE id = ?").get(id) as AIGenerationRecord | null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function updateAIGenerationOutputPath(id: number, outputPath: string): boolean {
|
|
707
|
+
const db = getDb();
|
|
708
|
+
const result = db.prepare("UPDATE ai_generations SET output_path = ? WHERE id = ?").run(outputPath, id);
|
|
709
|
+
return result.changes > 0;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function listSavedAIGenerations(collectionId: number): AIGenerationRecord[] {
|
|
713
|
+
const db = getDb();
|
|
714
|
+
return db.query(`
|
|
715
|
+
SELECT * FROM ai_generations
|
|
716
|
+
WHERE collection_id = ? AND output_path IS NOT NULL AND output_path != ''
|
|
717
|
+
ORDER BY created_at DESC
|
|
718
|
+
`).all(collectionId) as AIGenerationRecord[];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function findAIGenerationByYaml(collectionId: number, yaml: string): AIGenerationRecord | null {
|
|
722
|
+
const db = getDb();
|
|
723
|
+
return db.query(
|
|
724
|
+
"SELECT * FROM ai_generations WHERE collection_id = ? AND generated_yaml = ? ORDER BY created_at DESC LIMIT 1"
|
|
725
|
+
).get(collectionId, yaml) as AIGenerationRecord | null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ──────────────────────────────────────────────
|
|
729
|
+
// Settings
|
|
730
|
+
// ──────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
export function getSetting(key: string): string | null {
|
|
733
|
+
const db = getDb();
|
|
734
|
+
const row = db.query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
|
|
735
|
+
return row?.value ?? null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function setSetting(key: string, value: string): void {
|
|
739
|
+
const db = getDb();
|
|
740
|
+
db.prepare(`
|
|
741
|
+
INSERT INTO settings (key, value) VALUES ($key, $value)
|
|
742
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
743
|
+
`).run({ $key: key, $value: value });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export interface AISettingsConfig {
|
|
747
|
+
provider: string;
|
|
748
|
+
model: string;
|
|
749
|
+
base_url: string;
|
|
750
|
+
api_key: string;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function getAISettings(): AISettingsConfig {
|
|
754
|
+
return {
|
|
755
|
+
provider: getSetting("ai_provider") ?? "ollama",
|
|
756
|
+
model: getSetting("ai_model") ?? "",
|
|
757
|
+
base_url: getSetting("ai_base_url") ?? "",
|
|
758
|
+
api_key: getSetting("ai_api_key") ?? "",
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function setAISettings(config: Partial<AISettingsConfig>): void {
|
|
763
|
+
if (config.provider !== undefined) setSetting("ai_provider", config.provider);
|
|
764
|
+
if (config.model !== undefined) setSetting("ai_model", config.model);
|
|
765
|
+
if (config.base_url !== undefined) setSetting("ai_base_url", config.base_url);
|
|
766
|
+
if (config.api_key !== undefined) setSetting("ai_api_key", config.api_key);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ──────────────────────────────────────────────
|
|
770
|
+
// Chat Sessions & Messages
|
|
771
|
+
// ──────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
export interface ChatSessionRecord {
|
|
774
|
+
id: number;
|
|
775
|
+
title: string | null;
|
|
776
|
+
provider: string;
|
|
777
|
+
model: string;
|
|
778
|
+
created_at: string;
|
|
779
|
+
last_active: string;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export interface ChatMessageRecord {
|
|
783
|
+
id: number;
|
|
784
|
+
session_id: number;
|
|
785
|
+
role: string;
|
|
786
|
+
content: string;
|
|
787
|
+
tool_name: string | null;
|
|
788
|
+
tool_args: string | null;
|
|
789
|
+
tool_result: string | null;
|
|
790
|
+
input_tokens: number | null;
|
|
791
|
+
output_tokens: number | null;
|
|
792
|
+
created_at: string;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export interface SaveChatMessageOpts {
|
|
796
|
+
session_id: number;
|
|
797
|
+
role: string;
|
|
798
|
+
content: string;
|
|
799
|
+
tool_name?: string;
|
|
800
|
+
tool_args?: string;
|
|
801
|
+
tool_result?: string;
|
|
802
|
+
input_tokens?: number;
|
|
803
|
+
output_tokens?: number;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function createChatSession(provider: string, model: string, title?: string): number {
|
|
807
|
+
const db = getDb();
|
|
808
|
+
const result = db.prepare(`
|
|
809
|
+
INSERT INTO chat_sessions (title, provider, model)
|
|
810
|
+
VALUES ($title, $provider, $model)
|
|
811
|
+
`).run({
|
|
812
|
+
$title: title ?? null,
|
|
813
|
+
$provider: provider,
|
|
814
|
+
$model: model,
|
|
815
|
+
});
|
|
816
|
+
return Number(result.lastInsertRowid);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function saveChatMessage(opts: SaveChatMessageOpts): number {
|
|
820
|
+
const db = getDb();
|
|
821
|
+
|
|
822
|
+
// Update session last_active
|
|
823
|
+
db.prepare("UPDATE chat_sessions SET last_active = datetime('now') WHERE id = ?").run(opts.session_id);
|
|
824
|
+
|
|
825
|
+
const result = db.prepare(`
|
|
826
|
+
INSERT INTO chat_messages (session_id, role, content, tool_name, tool_args, tool_result, input_tokens, output_tokens)
|
|
827
|
+
VALUES ($session_id, $role, $content, $tool_name, $tool_args, $tool_result, $input_tokens, $output_tokens)
|
|
828
|
+
`).run({
|
|
829
|
+
$session_id: opts.session_id,
|
|
830
|
+
$role: opts.role,
|
|
831
|
+
$content: opts.content,
|
|
832
|
+
$tool_name: opts.tool_name ?? null,
|
|
833
|
+
$tool_args: opts.tool_args ?? null,
|
|
834
|
+
$tool_result: opts.tool_result ?? null,
|
|
835
|
+
$input_tokens: opts.input_tokens ?? null,
|
|
836
|
+
$output_tokens: opts.output_tokens ?? null,
|
|
837
|
+
});
|
|
838
|
+
return Number(result.lastInsertRowid);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function getChatMessages(sessionId: number): ChatMessageRecord[] {
|
|
842
|
+
const db = getDb();
|
|
843
|
+
return db.query(
|
|
844
|
+
"SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC"
|
|
845
|
+
).all(sessionId) as ChatMessageRecord[];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function listChatSessions(limit = 20): ChatSessionRecord[] {
|
|
849
|
+
const db = getDb();
|
|
850
|
+
return db.query(
|
|
851
|
+
"SELECT * FROM chat_sessions ORDER BY last_active DESC LIMIT ?"
|
|
852
|
+
).all(limit) as ChatSessionRecord[];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export interface CoreMessageFormat {
|
|
856
|
+
role: "user" | "assistant";
|
|
857
|
+
content: string;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function loadSessionHistory(sessionId: number): CoreMessageFormat[] {
|
|
861
|
+
const messages = getChatMessages(sessionId);
|
|
862
|
+
return messages
|
|
863
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
864
|
+
.map((m) => ({
|
|
865
|
+
role: m.role as "user" | "assistant",
|
|
866
|
+
content: m.content,
|
|
867
|
+
}));
|
|
868
|
+
}
|