zubo 0.1.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/.github/workflows/ci.yml +35 -0
- package/README.md +149 -0
- package/bun.lock +216 -0
- package/desktop/README.md +57 -0
- package/desktop/package.json +12 -0
- package/desktop/src-tauri/Cargo.toml +25 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/icons/README.md +17 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/main.rs +189 -0
- package/desktop/src-tauri/tauri.conf.json +68 -0
- package/docs/ROADMAP.md +490 -0
- package/migrations/001_init.sql +9 -0
- package/migrations/002_memory.sql +33 -0
- package/migrations/003_cron.sql +24 -0
- package/migrations/004_usage.sql +12 -0
- package/migrations/005_secrets.sql +8 -0
- package/migrations/006_agents.sql +1 -0
- package/migrations/007_workflows.sql +22 -0
- package/migrations/008_proactive.sql +24 -0
- package/migrations/009_uploads.sql +9 -0
- package/migrations/010_observability.sql +22 -0
- package/migrations/011_api_keys.sql +7 -0
- package/migrations/012_indexes.sql +5 -0
- package/migrations/013_budget.sql +11 -0
- package/migrations/014_usage_session_idx.sql +2 -0
- package/package.json +39 -0
- package/site/404.html +156 -0
- package/site/CNAME +1 -0
- package/site/docs/agents.html +294 -0
- package/site/docs/api.html +446 -0
- package/site/docs/channels.html +345 -0
- package/site/docs/cli.html +238 -0
- package/site/docs/config.html +1034 -0
- package/site/docs/index.html +433 -0
- package/site/docs/integrations.html +381 -0
- package/site/docs/memory.html +254 -0
- package/site/docs/security.html +375 -0
- package/site/docs/skills.html +322 -0
- package/site/docs.css +412 -0
- package/site/index.html +638 -0
- package/site/install.sh +98 -0
- package/site/logo.svg +1 -0
- package/site/og-image.png +0 -0
- package/site/robots.txt +4 -0
- package/site/script.js +361 -0
- package/site/sitemap.xml +63 -0
- package/site/skills.html +532 -0
- package/site/style.css +1686 -0
- package/src/agent/agents.ts +159 -0
- package/src/agent/compaction.ts +53 -0
- package/src/agent/context.ts +18 -0
- package/src/agent/delegate.ts +118 -0
- package/src/agent/loop.ts +318 -0
- package/src/agent/prompts.ts +111 -0
- package/src/agent/session.ts +87 -0
- package/src/agent/teams.ts +116 -0
- package/src/agent/workflow-executor.ts +192 -0
- package/src/agent/workflow.ts +175 -0
- package/src/channels/adapter.ts +21 -0
- package/src/channels/dashboard.html.ts +2969 -0
- package/src/channels/discord.ts +137 -0
- package/src/channels/optional-deps.d.ts +17 -0
- package/src/channels/router.ts +199 -0
- package/src/channels/signal.ts +133 -0
- package/src/channels/slack.ts +101 -0
- package/src/channels/telegram.ts +102 -0
- package/src/channels/utils.ts +18 -0
- package/src/channels/webchat.ts +1797 -0
- package/src/channels/whatsapp.ts +119 -0
- package/src/config/loader.ts +22 -0
- package/src/config/paths.ts +43 -0
- package/src/config/schema.ts +121 -0
- package/src/db/connection.ts +20 -0
- package/src/db/export.ts +148 -0
- package/src/db/migrations.ts +42 -0
- package/src/index.ts +261 -0
- package/src/llm/claude.ts +193 -0
- package/src/llm/factory.ts +115 -0
- package/src/llm/failover.ts +101 -0
- package/src/llm/openai-compat.ts +409 -0
- package/src/llm/provider.ts +83 -0
- package/src/llm/smart-router.ts +241 -0
- package/src/logs.ts +53 -0
- package/src/memory/chunker.ts +58 -0
- package/src/memory/document-parser.ts +115 -0
- package/src/memory/embedder.ts +235 -0
- package/src/memory/engine.ts +170 -0
- package/src/memory/fts-index.ts +55 -0
- package/src/memory/hybrid-search.ts +72 -0
- package/src/memory/store.ts +56 -0
- package/src/memory/vector-index.ts +72 -0
- package/src/model.ts +118 -0
- package/src/registry/cli.ts +43 -0
- package/src/registry/client.ts +54 -0
- package/src/registry/installer.ts +67 -0
- package/src/scheduler/briefing.ts +71 -0
- package/src/scheduler/cron.ts +258 -0
- package/src/scheduler/heartbeat.ts +58 -0
- package/src/scheduler/memory-triggers.ts +100 -0
- package/src/scheduler/natural-cron.ts +163 -0
- package/src/scheduler/proactive.ts +25 -0
- package/src/scheduler/recipes.ts +110 -0
- package/src/secrets/store.ts +64 -0
- package/src/setup.ts +413 -0
- package/src/skills.ts +293 -0
- package/src/start.ts +373 -0
- package/src/status.ts +165 -0
- package/src/tools/builtin/connect-service.ts +205 -0
- package/src/tools/builtin/cron.ts +126 -0
- package/src/tools/builtin/datetime.ts +36 -0
- package/src/tools/builtin/delegate-task.ts +81 -0
- package/src/tools/builtin/delegate.ts +42 -0
- package/src/tools/builtin/diagnose.ts +41 -0
- package/src/tools/builtin/google-oauth.ts +379 -0
- package/src/tools/builtin/manage-agents.ts +149 -0
- package/src/tools/builtin/manage-skills.ts +294 -0
- package/src/tools/builtin/manage-teams.ts +89 -0
- package/src/tools/builtin/manage-triggers.ts +94 -0
- package/src/tools/builtin/manage-workflows.ts +119 -0
- package/src/tools/builtin/memory-search.ts +38 -0
- package/src/tools/builtin/memory-write.ts +30 -0
- package/src/tools/builtin/run-workflow.ts +36 -0
- package/src/tools/builtin/secrets.ts +122 -0
- package/src/tools/builtin/skill-registry.ts +75 -0
- package/src/tools/builtin-integrations/api-helpers.ts +26 -0
- package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
- package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
- package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
- package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
- package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
- package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
- package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
- package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
- package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
- package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
- package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
- package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
- package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
- package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
- package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
- package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
- package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
- package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
- package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
- package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
- package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
- package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
- package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
- package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
- package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
- package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
- package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
- package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
- package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
- package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
- package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
- package/src/tools/builtin-skills/file-read/handler.ts +66 -0
- package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
- package/src/tools/builtin-skills/file-write/handler.ts +64 -0
- package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
- package/src/tools/builtin-skills/http-request/handler.ts +87 -0
- package/src/tools/builtin-skills/shell/SKILL.md +26 -0
- package/src/tools/builtin-skills/shell/handler.ts +96 -0
- package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
- package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
- package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
- package/src/tools/builtin-skills/web-search/handler.ts +50 -0
- package/src/tools/executor.ts +205 -0
- package/src/tools/integration-installer.ts +106 -0
- package/src/tools/permissions.ts +45 -0
- package/src/tools/registry.ts +39 -0
- package/src/tools/sandbox-runner.ts +56 -0
- package/src/tools/sandbox.ts +82 -0
- package/src/tools/skill-installer.ts +52 -0
- package/src/tools/skill-loader.ts +259 -0
- package/src/types/optional-deps.d.ts +23 -0
- package/src/util/auth.ts +121 -0
- package/src/util/costs.ts +59 -0
- package/src/util/error-buffer.ts +32 -0
- package/src/util/google-tokens.ts +180 -0
- package/src/util/logger.ts +73 -0
- package/src/util/perf-collector.ts +35 -0
- package/src/util/rate-limiter.ts +70 -0
- package/src/util/tokens.ts +17 -0
- package/src/voice/stt.ts +57 -0
- package/src/voice/tts.ts +103 -0
- package/tests/agent/session.test.ts +109 -0
- package/tests/agent-loop.test.ts +54 -0
- package/tests/auth.test.ts +89 -0
- package/tests/channels.test.ts +67 -0
- package/tests/compaction.test.ts +44 -0
- package/tests/config.test.ts +51 -0
- package/tests/costs.test.ts +19 -0
- package/tests/cron.test.ts +55 -0
- package/tests/db/export.test.ts +219 -0
- package/tests/executor.test.ts +144 -0
- package/tests/export.test.ts +137 -0
- package/tests/helpers/mock-llm.ts +34 -0
- package/tests/helpers/test-db.ts +74 -0
- package/tests/integration/chat-flow.test.ts +48 -0
- package/tests/integrations.test.ts +97 -0
- package/tests/memory/engine.test.ts +114 -0
- package/tests/memory-engine.test.ts +57 -0
- package/tests/permissions.test.ts +21 -0
- package/tests/rate-limiter.test.ts +70 -0
- package/tests/registry.test.ts +67 -0
- package/tests/router.test.ts +36 -0
- package/tests/session.test.ts +58 -0
- package/tests/skill-loader.test.ts +44 -0
- package/tests/tokens.test.ts +30 -0
- package/tests/tools/executor.test.ts +130 -0
- package/tests/util/auth.test.ts +75 -0
- package/tests/util/rate-limiter.test.ts +73 -0
- package/tests/voice.test.ts +60 -0
- package/tests/webchat.test.ts +88 -0
- package/tests/workflow.test.ts +38 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { compactMessages } from "../src/agent/compaction";
|
|
3
|
+
import type { LlmMessage } from "../src/llm/provider";
|
|
4
|
+
|
|
5
|
+
function makeMessages(count: number, charsEach: number): LlmMessage[] {
|
|
6
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
7
|
+
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
|
|
8
|
+
content: "x".repeat(charsEach),
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("compactMessages", () => {
|
|
13
|
+
it("returns messages unchanged when under limit", () => {
|
|
14
|
+
const msgs = makeMessages(4, 100);
|
|
15
|
+
const result = compactMessages(msgs, 150_000);
|
|
16
|
+
expect(result.length).toBe(4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("trims messages when over context window", () => {
|
|
20
|
+
// 50 messages x 10000 chars each = 500k chars ≈ 125k tokens
|
|
21
|
+
// With contextWindow of 1000 tokens, should compact heavily
|
|
22
|
+
const msgs = makeMessages(50, 10_000);
|
|
23
|
+
const result = compactMessages(msgs, 1_000);
|
|
24
|
+
expect(result.length).toBeLessThan(50);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("ensures first message is from user after compaction", () => {
|
|
28
|
+
const msgs = makeMessages(50, 10_000);
|
|
29
|
+
const result = compactMessages(msgs, 1_000);
|
|
30
|
+
expect(result[0].role).toBe("user");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps at least 2 messages", () => {
|
|
34
|
+
const msgs = makeMessages(50, 10_000);
|
|
35
|
+
const result = compactMessages(msgs, 1_000);
|
|
36
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses default context window when none provided", () => {
|
|
40
|
+
const msgs = makeMessages(4, 100);
|
|
41
|
+
const result = compactMessages(msgs);
|
|
42
|
+
expect(result.length).toBe(4);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { configSchema } from "../src/config/schema";
|
|
3
|
+
|
|
4
|
+
describe("Config Schema", () => {
|
|
5
|
+
it("should validate minimal config", () => {
|
|
6
|
+
const result = configSchema.safeParse({
|
|
7
|
+
anthropicApiKey: "test-key",
|
|
8
|
+
});
|
|
9
|
+
expect(result.success).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should provide defaults", () => {
|
|
13
|
+
const result = configSchema.parse({
|
|
14
|
+
anthropicApiKey: "test-key",
|
|
15
|
+
});
|
|
16
|
+
expect(result.maxTurns).toBe(50);
|
|
17
|
+
expect(result.heartbeatMinutes).toBe(30);
|
|
18
|
+
expect(result.telegramAllowedUsers).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should validate multi-provider config", () => {
|
|
22
|
+
const result = configSchema.safeParse({
|
|
23
|
+
providers: {
|
|
24
|
+
anthropic: { model: "claude-sonnet-4-5-20250929", apiKey: "test" },
|
|
25
|
+
openai: { model: "gpt-4o", apiKey: "test", baseUrl: "https://api.openai.com/v1" },
|
|
26
|
+
},
|
|
27
|
+
activeProvider: "anthropic",
|
|
28
|
+
});
|
|
29
|
+
expect(result.success).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should validate channel config", () => {
|
|
33
|
+
const result = configSchema.safeParse({
|
|
34
|
+
anthropicApiKey: "test",
|
|
35
|
+
channels: {
|
|
36
|
+
telegram: { botToken: "123:abc", allowedUsers: [12345] },
|
|
37
|
+
discord: { botToken: "discord-token" },
|
|
38
|
+
webchat: { port: 3000 },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(result.success).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should reject invalid heartbeat values", () => {
|
|
45
|
+
const result = configSchema.safeParse({
|
|
46
|
+
anthropicApiKey: "test",
|
|
47
|
+
heartbeatMinutes: 0,
|
|
48
|
+
});
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { estimateCost } from "../src/util/costs";
|
|
3
|
+
|
|
4
|
+
describe("Cost Estimation", () => {
|
|
5
|
+
it("should estimate cost for known models", () => {
|
|
6
|
+
const cost = estimateCost("gpt-4o", 1000, 500);
|
|
7
|
+
expect(cost).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should return 0 for unknown/local models", () => {
|
|
11
|
+
const cost = estimateCost("llama3:latest", 1000, 500);
|
|
12
|
+
expect(cost).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should handle zero tokens", () => {
|
|
16
|
+
const cost = estimateCost("gpt-4o", 0, 0);
|
|
17
|
+
expect(cost).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createTestDb } from "./helpers/test-db";
|
|
3
|
+
|
|
4
|
+
describe("Cron Jobs", () => {
|
|
5
|
+
it("should create cron_jobs table", () => {
|
|
6
|
+
const db = createTestDb();
|
|
7
|
+
const result = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='cron_jobs'").get() as any;
|
|
8
|
+
expect(result).toBeTruthy();
|
|
9
|
+
db.close();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should add a cron job", () => {
|
|
13
|
+
const db = createTestDb();
|
|
14
|
+
db.prepare("INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, ?)").run("test_job", "0 9 * * 1-5", "Send morning report", 1);
|
|
15
|
+
|
|
16
|
+
const jobs = db.query("SELECT * FROM cron_jobs").all() as any[];
|
|
17
|
+
expect(jobs.length).toBe(1);
|
|
18
|
+
expect(jobs[0].name).toBe("test_job");
|
|
19
|
+
expect(jobs[0].schedule).toBe("0 9 * * 1-5");
|
|
20
|
+
db.close();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should remove a cron job", () => {
|
|
24
|
+
const db = createTestDb();
|
|
25
|
+
db.prepare("INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, ?)").run("to_remove", "* * * * *", "test", 1);
|
|
26
|
+
db.prepare("DELETE FROM cron_jobs WHERE name = ?").run("to_remove");
|
|
27
|
+
|
|
28
|
+
const jobs = db.query("SELECT * FROM cron_jobs").all() as any[];
|
|
29
|
+
expect(jobs.length).toBe(0);
|
|
30
|
+
db.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should list cron jobs", () => {
|
|
34
|
+
const db = createTestDb();
|
|
35
|
+
db.prepare("INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, ?)").run("job1", "0 9 * * *", "task1", 1);
|
|
36
|
+
db.prepare("INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, ?)").run("job2", "0 17 * * *", "task2", 1);
|
|
37
|
+
|
|
38
|
+
const jobs = db.query("SELECT * FROM cron_jobs ORDER BY name").all() as any[];
|
|
39
|
+
expect(jobs.length).toBe(2);
|
|
40
|
+
expect(jobs[0].name).toBe("job1");
|
|
41
|
+
expect(jobs[1].name).toBe("job2");
|
|
42
|
+
db.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should enforce max jobs limit at DB level", () => {
|
|
46
|
+
const db = createTestDb();
|
|
47
|
+
// Insert many jobs -- there's no hard DB limit, but we can test we can add many
|
|
48
|
+
for (let i = 0; i < 50; i++) {
|
|
49
|
+
db.prepare("INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, ?)").run(`job_${i}`, "* * * * *", `task_${i}`, 1);
|
|
50
|
+
}
|
|
51
|
+
const count = (db.query("SELECT COUNT(*) as c FROM cron_jobs").get() as any)?.c;
|
|
52
|
+
expect(count).toBe(50);
|
|
53
|
+
db.close();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { exportDatabase, importDatabase } from "../../src/db/export";
|
|
7
|
+
|
|
8
|
+
function createTestDb(): Database {
|
|
9
|
+
const db = new Database(":memory:");
|
|
10
|
+
|
|
11
|
+
// Create the tables that EXPORT_TABLES references
|
|
12
|
+
db.run(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
channel TEXT NOT NULL,
|
|
16
|
+
user_id TEXT NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
18
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
db.run(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
source_file TEXT NOT NULL,
|
|
25
|
+
chunk_index INTEGER NOT NULL,
|
|
26
|
+
content TEXT NOT NULL,
|
|
27
|
+
embedding BLOB,
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
29
|
+
UNIQUE(source_file, chunk_index)
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
db.run(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
name TEXT NOT NULL UNIQUE,
|
|
36
|
+
schedule TEXT NOT NULL,
|
|
37
|
+
task TEXT NOT NULL,
|
|
38
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
39
|
+
last_run TEXT,
|
|
40
|
+
next_run TEXT,
|
|
41
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
43
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
db.run(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS cron_logs (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
job_id INTEGER NOT NULL REFERENCES cron_jobs(id),
|
|
50
|
+
status TEXT NOT NULL,
|
|
51
|
+
output TEXT,
|
|
52
|
+
error TEXT,
|
|
53
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
54
|
+
finished_at TEXT
|
|
55
|
+
)
|
|
56
|
+
`);
|
|
57
|
+
db.run(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
session_id TEXT,
|
|
61
|
+
provider TEXT NOT NULL,
|
|
62
|
+
model TEXT NOT NULL,
|
|
63
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
66
|
+
)
|
|
67
|
+
`);
|
|
68
|
+
db.run(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS uploads (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
filename TEXT NOT NULL,
|
|
72
|
+
original_name TEXT NOT NULL,
|
|
73
|
+
mime_type TEXT NOT NULL,
|
|
74
|
+
size_bytes INTEGER NOT NULL,
|
|
75
|
+
chunk_count INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
77
|
+
)
|
|
78
|
+
`);
|
|
79
|
+
db.run(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS tool_metrics (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
tool_name TEXT NOT NULL,
|
|
83
|
+
session_id TEXT,
|
|
84
|
+
duration_ms INTEGER NOT NULL,
|
|
85
|
+
success INTEGER NOT NULL DEFAULT 1,
|
|
86
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
87
|
+
)
|
|
88
|
+
`);
|
|
89
|
+
db.run(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS perf_snapshots (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
rss_mb REAL,
|
|
93
|
+
heap_mb REAL,
|
|
94
|
+
db_size_mb REAL,
|
|
95
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
96
|
+
)
|
|
97
|
+
`);
|
|
98
|
+
|
|
99
|
+
return db;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("importDatabase", () => {
|
|
103
|
+
let tempDir: string;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
tempDir = mkdtempSync(join(tmpdir(), "orba-export-test-"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("rejects unknown table names by skipping them", () => {
|
|
114
|
+
const db = createTestDb();
|
|
115
|
+
const importFile = join(tempDir, "import.json");
|
|
116
|
+
|
|
117
|
+
const data = {
|
|
118
|
+
version: 1,
|
|
119
|
+
exportedAt: new Date().toISOString(),
|
|
120
|
+
tables: {
|
|
121
|
+
evil_table: [{ id: 1, payload: "malicious" }],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
writeFileSync(importFile, JSON.stringify(data));
|
|
125
|
+
|
|
126
|
+
const result = importDatabase(db, importFile);
|
|
127
|
+
// The unknown table should be skipped entirely (not in EXPORT_TABLES)
|
|
128
|
+
expect(result.imported).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("handles empty data gracefully", () => {
|
|
132
|
+
const db = createTestDb();
|
|
133
|
+
const importFile = join(tempDir, "empty.json");
|
|
134
|
+
|
|
135
|
+
const data = {
|
|
136
|
+
version: 1,
|
|
137
|
+
exportedAt: new Date().toISOString(),
|
|
138
|
+
tables: {},
|
|
139
|
+
};
|
|
140
|
+
writeFileSync(importFile, JSON.stringify(data));
|
|
141
|
+
|
|
142
|
+
const result = importDatabase(db, importFile);
|
|
143
|
+
expect(result.imported).toBe(0);
|
|
144
|
+
expect(result.skipped).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rejects unsupported export version", () => {
|
|
148
|
+
const db = createTestDb();
|
|
149
|
+
const importFile = join(tempDir, "bad-version.json");
|
|
150
|
+
|
|
151
|
+
const data = {
|
|
152
|
+
version: 99,
|
|
153
|
+
exportedAt: new Date().toISOString(),
|
|
154
|
+
tables: {},
|
|
155
|
+
};
|
|
156
|
+
writeFileSync(importFile, JSON.stringify(data));
|
|
157
|
+
|
|
158
|
+
expect(() => importDatabase(db, importFile)).toThrow("Unsupported export version");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("export/import round-trip", () => {
|
|
163
|
+
let tempDir: string;
|
|
164
|
+
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
tempDir = mkdtempSync(join(tmpdir(), "orba-roundtrip-test-"));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("preserves data through export and import", () => {
|
|
174
|
+
const sourceDb = createTestDb();
|
|
175
|
+
|
|
176
|
+
// Insert test data into sessions
|
|
177
|
+
sourceDb
|
|
178
|
+
.prepare("INSERT INTO sessions (id, channel, user_id) VALUES (?, ?, ?)")
|
|
179
|
+
.run("sess-1", "discord", "user-42");
|
|
180
|
+
sourceDb
|
|
181
|
+
.prepare("INSERT INTO sessions (id, channel, user_id) VALUES (?, ?, ?)")
|
|
182
|
+
.run("sess-2", "telegram", "user-99");
|
|
183
|
+
|
|
184
|
+
// Insert test data into memory_chunks
|
|
185
|
+
sourceDb
|
|
186
|
+
.prepare(
|
|
187
|
+
"INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)"
|
|
188
|
+
)
|
|
189
|
+
.run("notes.md", 0, "Remember this fact");
|
|
190
|
+
|
|
191
|
+
// Export
|
|
192
|
+
const exportPath = join(tempDir, "export.json");
|
|
193
|
+
exportDatabase(sourceDb, exportPath);
|
|
194
|
+
|
|
195
|
+
// Verify the export file was created
|
|
196
|
+
const exportContent = JSON.parse(readFileSync(exportPath, "utf-8"));
|
|
197
|
+
expect(exportContent.version).toBe(1);
|
|
198
|
+
expect(exportContent.tables.sessions).toHaveLength(2);
|
|
199
|
+
expect(exportContent.tables.memory_chunks).toHaveLength(1);
|
|
200
|
+
|
|
201
|
+
// Import into a fresh database
|
|
202
|
+
const targetDb = createTestDb();
|
|
203
|
+
const result = importDatabase(targetDb, exportPath);
|
|
204
|
+
|
|
205
|
+
expect(result.imported).toBe(3); // 2 sessions + 1 memory chunk
|
|
206
|
+
|
|
207
|
+
// Verify the data
|
|
208
|
+
const sessions = targetDb.query("SELECT * FROM sessions ORDER BY id").all() as any[];
|
|
209
|
+
expect(sessions).toHaveLength(2);
|
|
210
|
+
expect(sessions[0].id).toBe("sess-1");
|
|
211
|
+
expect(sessions[0].channel).toBe("discord");
|
|
212
|
+
expect(sessions[1].id).toBe("sess-2");
|
|
213
|
+
expect(sessions[1].channel).toBe("telegram");
|
|
214
|
+
|
|
215
|
+
const chunks = targetDb.query("SELECT * FROM memory_chunks").all() as any[];
|
|
216
|
+
expect(chunks).toHaveLength(1);
|
|
217
|
+
expect(chunks[0].content).toBe("Remember this fact");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { registerTool, unregisterTool } from "../src/tools/registry";
|
|
3
|
+
import { executeTool } from "../src/tools/executor";
|
|
4
|
+
|
|
5
|
+
describe("Tool Executor", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
// Register a test tool
|
|
8
|
+
registerTool({
|
|
9
|
+
definition: {
|
|
10
|
+
name: "test_tool",
|
|
11
|
+
description: "A test tool",
|
|
12
|
+
input_schema: { type: "object", properties: { msg: { type: "string" } } },
|
|
13
|
+
},
|
|
14
|
+
async execute(input) {
|
|
15
|
+
return `echo: ${input.msg}`;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
unregisterTool("test_tool");
|
|
22
|
+
unregisterTool("error_tool");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should execute a registered tool", async () => {
|
|
26
|
+
const result = await executeTool("test_tool", "t1", { msg: "hello" });
|
|
27
|
+
expect(result.content).toBe("echo: hello");
|
|
28
|
+
expect(result.is_error).toBe(false);
|
|
29
|
+
expect(result.tool_use_id).toBe("t1");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should return error for unknown tool", async () => {
|
|
33
|
+
const result = await executeTool("nonexistent_tool", "t2", {});
|
|
34
|
+
expect(result.is_error).toBe(true);
|
|
35
|
+
expect(result.content).toContain("Unknown tool");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should block tools not in allowedTools", async () => {
|
|
39
|
+
const result = await executeTool("test_tool", "t3", { msg: "hi" }, ["other_tool"]);
|
|
40
|
+
expect(result.is_error).toBe(true);
|
|
41
|
+
expect(result.content).toContain("not available");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should handle tool execution errors", async () => {
|
|
45
|
+
registerTool({
|
|
46
|
+
definition: {
|
|
47
|
+
name: "error_tool",
|
|
48
|
+
description: "Always fails",
|
|
49
|
+
input_schema: { type: "object" },
|
|
50
|
+
},
|
|
51
|
+
async execute() {
|
|
52
|
+
throw new Error("intentional failure");
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await executeTool("error_tool", "t4", {});
|
|
57
|
+
expect(result.is_error).toBe(true);
|
|
58
|
+
expect(result.content).toContain("intentional failure");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should require confirmation for confirm-permission tools", async () => {
|
|
62
|
+
// Register the shell tool so executeTool can find it
|
|
63
|
+
registerTool({
|
|
64
|
+
definition: {
|
|
65
|
+
name: "shell",
|
|
66
|
+
description: "Run shell commands",
|
|
67
|
+
input_schema: { type: "object", properties: { command: { type: "string" } } },
|
|
68
|
+
},
|
|
69
|
+
async execute(input) {
|
|
70
|
+
return `ran: ${input.command}`;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await executeTool("shell", "t5", { command: "ls" });
|
|
75
|
+
// shell has "confirm" permission, so it should request confirmation
|
|
76
|
+
expect(result.content).toContain("CONFIRMATION REQUIRED");
|
|
77
|
+
expect(result.content).toContain("Confirmation Token:");
|
|
78
|
+
|
|
79
|
+
unregisterTool("shell");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should accept valid confirmation token", async () => {
|
|
83
|
+
registerTool({
|
|
84
|
+
definition: {
|
|
85
|
+
name: "shell",
|
|
86
|
+
description: "Run shell commands",
|
|
87
|
+
input_schema: { type: "object", properties: { command: { type: "string" } } },
|
|
88
|
+
},
|
|
89
|
+
async execute(input) {
|
|
90
|
+
return `ran: ${input.command}`;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// First call gets the token
|
|
95
|
+
const first = await executeTool("shell", "t6", { command: "ls" });
|
|
96
|
+
expect(first.content).toContain("Confirmation Token:");
|
|
97
|
+
const tokenMatch = first.content.match(/Confirmation Token: ([a-f0-9-]+)/);
|
|
98
|
+
expect(tokenMatch).toBeTruthy();
|
|
99
|
+
|
|
100
|
+
// Second call with valid token should execute
|
|
101
|
+
const second = await executeTool("shell", "t7", { command: "ls", _confirmToken: tokenMatch![1] });
|
|
102
|
+
expect(second.is_error).toBe(false);
|
|
103
|
+
expect(second.content).toBe("ran: ls");
|
|
104
|
+
|
|
105
|
+
unregisterTool("shell");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should reject spoofed _confirmed without token", async () => {
|
|
109
|
+
registerTool({
|
|
110
|
+
definition: {
|
|
111
|
+
name: "shell",
|
|
112
|
+
description: "Run shell commands",
|
|
113
|
+
input_schema: { type: "object", properties: { command: { type: "string" } } },
|
|
114
|
+
},
|
|
115
|
+
async execute(input) {
|
|
116
|
+
return `ran: ${input.command}`;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Try to bypass with _confirmed: true — should still require token
|
|
121
|
+
const result = await executeTool("shell", "t8", { command: "rm -rf /", _confirmed: true });
|
|
122
|
+
expect(result.content).toContain("CONFIRMATION REQUIRED");
|
|
123
|
+
|
|
124
|
+
unregisterTool("shell");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should reject invalid confirmation token", async () => {
|
|
128
|
+
registerTool({
|
|
129
|
+
definition: {
|
|
130
|
+
name: "shell",
|
|
131
|
+
description: "Run shell commands",
|
|
132
|
+
input_schema: { type: "object", properties: { command: { type: "string" } } },
|
|
133
|
+
},
|
|
134
|
+
async execute(input) {
|
|
135
|
+
return `ran: ${input.command}`;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = await executeTool("shell", "t9", { command: "ls", _confirmToken: "fake-token-12345" });
|
|
140
|
+
expect(result.content).toContain("Invalid or expired confirmation token");
|
|
141
|
+
|
|
142
|
+
unregisterTool("shell");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { exportDatabase, importDatabase, getDbStats, backupDatabase } from "../src/db/export";
|
|
7
|
+
|
|
8
|
+
function createExportTestDb(): Database {
|
|
9
|
+
const db = new Database(":memory:");
|
|
10
|
+
|
|
11
|
+
// Create tables that export uses
|
|
12
|
+
db.run(`CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, channel TEXT, user_id TEXT, created_at TEXT, updated_at TEXT)`);
|
|
13
|
+
db.run(`CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, created_at TEXT)`);
|
|
14
|
+
db.run(`CREATE TABLE IF NOT EXISTS memory_chunks (id INTEGER PRIMARY KEY, source_file TEXT, chunk_index INTEGER, content TEXT)`);
|
|
15
|
+
db.run(`CREATE TABLE IF NOT EXISTS cron_jobs (id INTEGER PRIMARY KEY, name TEXT, schedule TEXT, task TEXT, enabled INTEGER DEFAULT 1, last_run TEXT, next_run TEXT)`);
|
|
16
|
+
db.run(`CREATE TABLE IF NOT EXISTS usage (id INTEGER PRIMARY KEY, session_id TEXT, provider TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, created_at TEXT)`);
|
|
17
|
+
db.run(`CREATE TABLE IF NOT EXISTS uploads (id INTEGER PRIMARY KEY, filename TEXT, original_name TEXT, mime_type TEXT, size_bytes INTEGER, chunk_count INTEGER)`);
|
|
18
|
+
|
|
19
|
+
return db;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("Database Export", () => {
|
|
23
|
+
let db: Database;
|
|
24
|
+
let tmpDir: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
db = createExportTestDb();
|
|
28
|
+
tmpDir = join(tmpdir(), `zubo-test-${Date.now()}`);
|
|
29
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should export database as valid JSON", () => {
|
|
33
|
+
// Insert test data
|
|
34
|
+
db.run("INSERT INTO sessions (id, channel, user_id, created_at, updated_at) VALUES ('s1', 'webchat', 'user1', '2024-01-01', '2024-01-01')");
|
|
35
|
+
db.run("INSERT INTO messages (session_id, role, content, created_at) VALUES ('s1', 'user', 'Hello', '2024-01-01')");
|
|
36
|
+
db.run("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES ('test.md', 0, 'Test memory')");
|
|
37
|
+
|
|
38
|
+
const outputPath = join(tmpDir, "export.json");
|
|
39
|
+
exportDatabase(db, outputPath);
|
|
40
|
+
|
|
41
|
+
expect(existsSync(outputPath)).toBe(true);
|
|
42
|
+
|
|
43
|
+
const data = JSON.parse(readFileSync(outputPath, "utf-8"));
|
|
44
|
+
expect(data.version).toBe(1);
|
|
45
|
+
expect(data.exportedAt).toBeTruthy();
|
|
46
|
+
expect(data.tables.sessions).toHaveLength(1);
|
|
47
|
+
expect(data.tables.messages).toHaveLength(1);
|
|
48
|
+
expect(data.tables.memory_chunks).toHaveLength(1);
|
|
49
|
+
|
|
50
|
+
try { unlinkSync(outputPath); } catch {}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should import data from JSON", () => {
|
|
54
|
+
const exportData = {
|
|
55
|
+
version: 1,
|
|
56
|
+
exportedAt: new Date().toISOString(),
|
|
57
|
+
tables: {
|
|
58
|
+
sessions: [{ id: "s1", channel: "webchat", user_id: "user1", created_at: "2024-01-01", updated_at: "2024-01-01" }],
|
|
59
|
+
messages: [{ id: 1, session_id: "s1", role: "user", content: "Hello", created_at: "2024-01-01" }],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const inputPath = join(tmpDir, "import.json");
|
|
64
|
+
const { writeFileSync } = require("fs");
|
|
65
|
+
writeFileSync(inputPath, JSON.stringify(exportData));
|
|
66
|
+
|
|
67
|
+
const result = importDatabase(db, inputPath);
|
|
68
|
+
expect(result.imported).toBeGreaterThan(0);
|
|
69
|
+
|
|
70
|
+
// Verify data was imported
|
|
71
|
+
const sessions = db.query("SELECT * FROM sessions").all();
|
|
72
|
+
expect(sessions).toHaveLength(1);
|
|
73
|
+
|
|
74
|
+
const messages = db.query("SELECT * FROM messages").all();
|
|
75
|
+
expect(messages).toHaveLength(1);
|
|
76
|
+
|
|
77
|
+
try { unlinkSync(inputPath); } catch {}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should skip duplicate rows on import", () => {
|
|
81
|
+
// Insert existing data
|
|
82
|
+
db.run("INSERT INTO sessions (id, channel, user_id, created_at, updated_at) VALUES ('s1', 'webchat', 'user1', '2024-01-01', '2024-01-01')");
|
|
83
|
+
|
|
84
|
+
const exportData = {
|
|
85
|
+
version: 1,
|
|
86
|
+
exportedAt: new Date().toISOString(),
|
|
87
|
+
tables: {
|
|
88
|
+
sessions: [{ id: "s1", channel: "webchat", user_id: "user1", created_at: "2024-01-01", updated_at: "2024-01-01" }],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const inputPath = join(tmpDir, "import-dup.json");
|
|
93
|
+
const { writeFileSync } = require("fs");
|
|
94
|
+
writeFileSync(inputPath, JSON.stringify(exportData));
|
|
95
|
+
|
|
96
|
+
const result = importDatabase(db, inputPath);
|
|
97
|
+
// Should have been handled (either imported or skipped, no error)
|
|
98
|
+
expect(result.imported + result.skipped).toBeGreaterThanOrEqual(1);
|
|
99
|
+
|
|
100
|
+
// Should still have exactly one session
|
|
101
|
+
const sessions = db.query("SELECT * FROM sessions").all();
|
|
102
|
+
expect(sessions).toHaveLength(1);
|
|
103
|
+
|
|
104
|
+
try { unlinkSync(inputPath); } catch {}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should get database stats", () => {
|
|
108
|
+
db.run("INSERT INTO sessions (id, channel, user_id, created_at, updated_at) VALUES ('s1', 'webchat', 'user1', '2024-01-01', '2024-01-01')");
|
|
109
|
+
db.run("INSERT INTO messages (session_id, role, content, created_at) VALUES ('s1', 'user', 'Hello', '2024-01-01')");
|
|
110
|
+
db.run("INSERT INTO messages (session_id, role, content, created_at) VALUES ('s1', 'assistant', 'Hi', '2024-01-01')");
|
|
111
|
+
|
|
112
|
+
const stats = getDbStats(db);
|
|
113
|
+
expect(stats.tables.sessions).toBe(1);
|
|
114
|
+
expect(stats.tables.messages).toBe(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should create SQLite backup file", () => {
|
|
118
|
+
// Create a real on-disk DB for backup test
|
|
119
|
+
const dbPath = join(tmpDir, "test.db");
|
|
120
|
+
const diskDb = new Database(dbPath, { create: true });
|
|
121
|
+
diskDb.run("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)");
|
|
122
|
+
diskDb.run("INSERT INTO test VALUES (1, 'hello')");
|
|
123
|
+
diskDb.close();
|
|
124
|
+
|
|
125
|
+
const backupPath = backupDatabase(dbPath, tmpDir);
|
|
126
|
+
expect(existsSync(backupPath)).toBe(true);
|
|
127
|
+
|
|
128
|
+
// Verify backup is a valid SQLite database
|
|
129
|
+
const backupDb = new Database(backupPath, { readonly: true });
|
|
130
|
+
const rows = backupDb.query("SELECT * FROM test").all() as any[];
|
|
131
|
+
expect(rows).toHaveLength(1);
|
|
132
|
+
expect(rows[0].value).toBe("hello");
|
|
133
|
+
backupDb.close();
|
|
134
|
+
|
|
135
|
+
try { unlinkSync(dbPath); unlinkSync(backupPath); } catch {}
|
|
136
|
+
});
|
|
137
|
+
});
|