@desplega.ai/agent-swarm 1.157.0 → 1.158.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/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
- package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
- package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
- package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
- package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
- package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
- package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
- package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
- package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
- package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
- package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
- package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
- package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
- package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
- package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
- package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
- package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
- package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
- package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
- package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
- package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
- package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
- package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
- package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
- package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
- package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
- package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
- package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
- package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
- package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
- package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
- package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
- package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
- package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
- package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
- package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
- package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
- package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
- package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
- package/dist/cli-t1ze64h3.js +811 -0
- package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
- package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
- package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
- package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
- package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
- package/dist/cli.js +29 -15
- package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
- package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
- package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
- package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
- package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
- package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
- package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
- package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
- package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
- package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
- package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
- package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
- package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
- package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
- package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
- package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
- package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
- package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
- package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
- package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
- package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
- package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
- package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
- package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
- package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
- package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
- package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
- package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
- package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
- package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
- package/openapi.json +1626 -629
- package/package.json +2 -1
- package/src/apps/catalog.generated.json +229 -0
- package/src/apps/page-validator.ts +71 -16
- package/src/apps/row-store.ts +66 -11
- package/src/apps/sync.ts +59 -17
- package/src/artifact-sdk/browser-sdk.ts +436 -2
- package/src/be/approval-sweeps.ts +109 -0
- package/src/be/boot-seeds.ts +40 -0
- package/src/be/chunking.ts +1 -1
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/approvals.ts +78 -0
- package/src/be/db/tasks/read.ts +197 -39
- package/src/be/db/tasks/write.ts +41 -9
- package/src/be/db-client.ts +107 -48
- package/src/be/db.ts +219 -76
- package/src/be/events.ts +33 -1
- package/src/be/memory/graph-expansion.ts +10 -3
- package/src/be/memory/providers/sqlite-store.ts +55 -13
- package/src/be/memory/raters/retrieval.ts +5 -2
- package/src/be/memory/reranker.ts +26 -2
- package/src/be/memory/types.ts +2 -0
- package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
- package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
- package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
- package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
- package/src/be/models-catalog.ts +2 -0
- package/src/be/modelsdev-cache.json +1857 -0
- package/src/be/modelsdev-cache.ts +14 -1
- package/src/be/pricing-normalize.ts +2 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +3 -1
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/steering.ts +53 -3
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/be/task-citations.ts +29 -0
- package/src/cli.tsx +14 -1
- package/src/commands/onboard/compose-generator.ts +8 -3
- package/src/commands/onboard/container-engine.ts +220 -0
- package/src/commands/onboard/manifest.ts +1 -0
- package/src/commands/onboard/steps/health-check.tsx +4 -1
- package/src/commands/onboard/steps/prereq-check.tsx +50 -54
- package/src/commands/onboard/steps/start.tsx +31 -16
- package/src/commands/onboard/types.ts +7 -2
- package/src/commands/onboard.tsx +39 -4
- package/src/commands/profile-sync.ts +79 -26
- package/src/commands/runner.ts +8 -1
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +263 -103
- package/src/hooks/hook.ts +21 -12
- package/src/http/agents.ts +35 -0
- package/src/http/approval-requests.ts +136 -64
- package/src/http/apps.ts +14 -3
- package/src/http/config.ts +15 -1
- package/src/http/events.ts +62 -2
- package/src/http/index.ts +6 -4
- package/src/http/memory.ts +21 -7
- package/src/http/schedules.ts +1 -1
- package/src/http/session-data.ts +9 -4
- package/src/http/sessions.ts +25 -18
- package/src/http/stats.ts +19 -7
- package/src/http/status.ts +11 -1
- package/src/http/tasks.ts +40 -8
- package/src/http/webhooks.ts +67 -46
- package/src/http/workflows.ts +15 -20
- package/src/linear/outbound.ts +37 -15
- package/src/otel-impl.ts +151 -1
- package/src/otel.ts +14 -3
- package/src/prompts/memories.ts +19 -2
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/providers/modelsdev-reasoning.json +551 -0
- package/src/rbac/legacy-policy.ts +16 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/scheduler/schedule-task.ts +3 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +9 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +12 -7
- package/src/slack/app.ts +35 -4
- package/src/slack/connection-state.ts +17 -0
- package/src/tasks/context-key.ts +1 -0
- package/src/tasks/worker-follow-up.ts +6 -2
- package/src/tests/agent-task-activity.test.ts +74 -0
- package/src/tests/agents-list-model-display.test.ts +6 -1
- package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
- package/src/tests/approval-requests.test.ts +395 -0
- package/src/tests/approval-sweeps-config.test.ts +47 -0
- package/src/tests/approval-sweeps.test.ts +508 -0
- package/src/tests/apps-kibo-blocks.test.ts +77 -0
- package/src/tests/apps-spike.test.ts +56 -1
- package/src/tests/apps-sync-engine.test.ts +20 -9
- package/src/tests/boot-seeds.test.ts +170 -0
- package/src/tests/cancel-approval-request-tool.test.ts +115 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/config-key-filter.test.ts +80 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/db-client-busy-retry.test.ts +96 -0
- package/src/tests/db-client.test.ts +60 -1
- package/src/tests/db-task-read-contract.test.ts +10 -0
- package/src/tests/db-task-write-contract.test.ts +9 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/events-http.test.ts +103 -0
- package/src/tests/extensions-catalog.test.ts +22 -17
- package/src/tests/extensions-pre-task.test.ts +107 -0
- package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
- package/src/tests/follow-up-config-inheritance.test.ts +260 -0
- package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
- package/src/tests/heartbeat-reboot-live.test.ts +95 -0
- package/src/tests/heartbeat-stall-cas.test.ts +80 -0
- package/src/tests/heartbeat.test.ts +99 -3
- package/src/tests/hook-mcp-config.test.ts +29 -0
- package/src/tests/http-api-integration.test.ts +16 -2
- package/src/tests/kv-storage.test.ts +62 -0
- package/src/tests/linear-outbound-sync.test.ts +120 -5
- package/src/tests/list-endpoint-slimming.test.ts +91 -0
- package/src/tests/memory-graph-organic-route.test.ts +155 -0
- package/src/tests/memory-http-recall-gating.test.ts +14 -4
- package/src/tests/memory-hybrid.test.ts +161 -1
- package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
- package/src/tests/memory.test.ts +22 -0
- package/src/tests/onboard-container-engine.test.ts +240 -0
- package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
- package/src/tests/pricing-routes.test.ts +14 -0
- package/src/tests/profile-sync-warning.test.ts +8 -4
- package/src/tests/promote-draft-task-route.test.ts +19 -0
- package/src/tests/provider-registration.test.ts +296 -42
- package/src/tests/rbac-engine.test.ts +30 -0
- package/src/tests/reasoning-effort.test.ts +8 -0
- package/src/tests/script-connections.test.ts +7 -0
- package/src/tests/sdk-allowlist.test.ts +39 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions-detail-batch.test.ts +250 -0
- package/src/tests/sessions.test.ts +118 -0
- package/src/tests/slack-config.test.ts +22 -0
- package/src/tests/sonnet-5-5-registry.test.ts +44 -0
- package/src/tests/status.test.ts +36 -1
- package/src/tests/swarm-diff.test.ts +316 -10
- package/src/tests/task-completion-idempotency.test.ts +2 -1
- package/src/tests/tasks-requested-by-filter.test.ts +3 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/ui-list-slim-projections.test.ts +158 -0
- package/src/tests/webhooks-task-blocked.test.ts +127 -0
- package/src/tests/workflow-approval-recovery.test.ts +203 -0
- package/src/tests/workflow-hitl-routing.test.ts +22 -1
- package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
- package/src/tests/workflow-http-v2.test.ts +99 -8
- package/src/tests/workflow-retry-validation.test.ts +56 -0
- package/src/tests/workflow-tla-races.test.ts +389 -0
- package/src/tests/workflow-validation-port-routing.test.ts +113 -1
- package/src/tools/cancel-approval-request.ts +57 -0
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/request-human-input.ts +4 -2
- package/src/tools/send-task.ts +4 -0
- package/src/tools/templates.ts +19 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/workflows/list-workflow-runs.ts +1 -0
- package/src/types.ts +38 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/approval-cancel.ts +152 -0
- package/src/workflows/approval-notifications.ts +84 -0
- package/src/workflows/checkpoint.ts +10 -5
- package/src/workflows/definition.ts +21 -0
- package/src/workflows/engine.ts +152 -21
- package/src/workflows/executors/human-in-the-loop.ts +1 -1
- package/src/workflows/recovery.ts +100 -45
- package/src/workflows/resume.ts +49 -18
- package/src/workflows/retry-poller.ts +71 -21
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/github-sender-allowlist/README.md +18 -0
- package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
- package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
- package/templates/skills/apps/content.md +20 -1
- package/templates/skills/code-quality/content.md +26 -1
- package/templates/skills/pages/content.md +16 -4
- package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
- package/dist/cli-2daksqps.js +0 -378
- package/dist/rbac-roles-3gxmnb20.js +0 -37
|
@@ -26,7 +26,12 @@ import {
|
|
|
26
26
|
withMutationLock,
|
|
27
27
|
} from "../apps/row-store";
|
|
28
28
|
import { createApp, getApp, updateApp } from "../apps/store";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
getAppSyncStatus,
|
|
31
|
+
RECONCILE_CHUNK_SIZE,
|
|
32
|
+
runAppSync,
|
|
33
|
+
type SyncPassResult,
|
|
34
|
+
} from "../apps/sync";
|
|
30
35
|
import {
|
|
31
36
|
closeDb,
|
|
32
37
|
createAgent,
|
|
@@ -1277,17 +1282,20 @@ spawnDescribe("concurrency", () => {
|
|
|
1277
1282
|
});
|
|
1278
1283
|
|
|
1279
1284
|
test("a mid-pass write failure reports the committed churn instead of zero counts", async () => {
|
|
1280
|
-
//
|
|
1281
|
-
// SECOND
|
|
1282
|
-
//
|
|
1283
|
-
|
|
1285
|
+
// Rows are written in chunked transactions: fail the second create of the
|
|
1286
|
+
// SECOND chunk after the first chunk committed. The pass counts must say
|
|
1287
|
+
// so instead of reporting the zero-count base, and the failing chunk must
|
|
1288
|
+
// roll back without leaving partial rows.
|
|
1289
|
+
const total = RECONCILE_CHUNK_SIZE + 2;
|
|
1290
|
+
const records = Array.from({ length: total }, (_, index) => ghRecord(index + 1));
|
|
1291
|
+
const script = await fixtureScript("partial", records);
|
|
1284
1292
|
const appId = await createSyncApp(issueDefinition(script.id));
|
|
1285
1293
|
|
|
1286
1294
|
const realCreate = rowStore.createAppRowUnlocked;
|
|
1287
1295
|
let creates = 0;
|
|
1288
1296
|
const spy = spyOn(rowStore, "createAppRowUnlocked").mockImplementation((...callArgs) => {
|
|
1289
1297
|
creates += 1;
|
|
1290
|
-
if (creates === 2) throw new Error("kv write failed (injected)");
|
|
1298
|
+
if (creates === RECONCILE_CHUNK_SIZE + 2) throw new Error("kv write failed (injected)");
|
|
1291
1299
|
return realCreate(...callArgs);
|
|
1292
1300
|
});
|
|
1293
1301
|
try {
|
|
@@ -1296,9 +1304,12 @@ spawnDescribe("concurrency", () => {
|
|
|
1296
1304
|
|
|
1297
1305
|
expect(result.ok).toBe(false);
|
|
1298
1306
|
expect(pass.error).toContain("kv write failed (injected)");
|
|
1299
|
-
expect(pass).toMatchObject({ pulled:
|
|
1300
|
-
expect(
|
|
1301
|
-
expect(await getAppSyncStatus(appId, "issue", "gh")).toMatchObject({
|
|
1307
|
+
expect(pass).toMatchObject({ pulled: total, created: RECONCILE_CHUNK_SIZE });
|
|
1308
|
+
expect(await rowsOf(appId)).toHaveLength(RECONCILE_CHUNK_SIZE);
|
|
1309
|
+
expect(await getAppSyncStatus(appId, "issue", "gh")).toMatchObject({
|
|
1310
|
+
ok: false,
|
|
1311
|
+
created: RECONCILE_CHUNK_SIZE,
|
|
1312
|
+
});
|
|
1302
1313
|
} finally {
|
|
1303
1314
|
spy.mockRestore();
|
|
1304
1315
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensurePricingSeeded, ensureRbacSeeded } from "../be/boot-seeds";
|
|
6
|
+
import { closeDb, getDbClient, initDb } from "../be/db";
|
|
7
|
+
import { loadModelsDevCache } from "../be/modelsdev-cache";
|
|
8
|
+
import { BUILTIN_ROLES } from "../be/rbac-roles";
|
|
9
|
+
import { createServer } from "../server";
|
|
10
|
+
|
|
11
|
+
const TEST_DB_PATH = "./test-boot-seeds.sqlite";
|
|
12
|
+
const BUILTIN_ROLE_ID = "rbac-role-requester";
|
|
13
|
+
|
|
14
|
+
async function removeDbFiles(): Promise<void> {
|
|
15
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
16
|
+
try {
|
|
17
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
18
|
+
} catch {
|
|
19
|
+
// File does not exist.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function countPricingSeedRows(): Promise<number> {
|
|
25
|
+
const row = await getDbClient().get<{ n: number }>(
|
|
26
|
+
"SELECT COUNT(*) AS n FROM pricing WHERE effective_from = 0",
|
|
27
|
+
);
|
|
28
|
+
return row?.n ?? 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function countBuiltinPermissions(): Promise<number> {
|
|
32
|
+
const row = await getDbClient().get<{ n: number }>(
|
|
33
|
+
"SELECT COUNT(*) AS n FROM role_permissions WHERE roleId = ?",
|
|
34
|
+
[BUILTIN_ROLE_ID],
|
|
35
|
+
);
|
|
36
|
+
return row?.n ?? 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fresh DB handle per test: the guards are keyed on the live handle.
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
closeDb();
|
|
42
|
+
await removeDbFiles();
|
|
43
|
+
initDb(TEST_DB_PATH);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
closeDb();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await removeDbFiles();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("ensurePricingSeeded", () => {
|
|
55
|
+
test("seeds once per database handle, and again for a new handle", async () => {
|
|
56
|
+
ensurePricingSeeded();
|
|
57
|
+
const seeded = await countPricingSeedRows();
|
|
58
|
+
expect(seeded).toBeGreaterThan(0);
|
|
59
|
+
|
|
60
|
+
// A second call must not touch the table: wipe it and confirm it stays empty.
|
|
61
|
+
await getDbClient().run("DELETE FROM pricing WHERE effective_from = 0");
|
|
62
|
+
ensurePricingSeeded();
|
|
63
|
+
expect(await countPricingSeedRows()).toBe(0);
|
|
64
|
+
|
|
65
|
+
// Reopening the same file gives a new handle, which is seeded again.
|
|
66
|
+
closeDb();
|
|
67
|
+
initDb(TEST_DB_PATH);
|
|
68
|
+
ensurePricingSeeded();
|
|
69
|
+
expect(await countPricingSeedRows()).toBe(seeded);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("ensureRbacSeeded", () => {
|
|
74
|
+
test("syncs once per database handle, and again for a new handle", async () => {
|
|
75
|
+
ensureRbacSeeded();
|
|
76
|
+
const seeded = await countBuiltinPermissions();
|
|
77
|
+
expect(seeded).toBeGreaterThan(0);
|
|
78
|
+
|
|
79
|
+
await getDbClient().run("DELETE FROM role_permissions WHERE roleId = ?", [BUILTIN_ROLE_ID]);
|
|
80
|
+
ensureRbacSeeded();
|
|
81
|
+
expect(await countBuiltinPermissions()).toBe(0);
|
|
82
|
+
|
|
83
|
+
closeDb();
|
|
84
|
+
initDb(TEST_DB_PATH);
|
|
85
|
+
ensureRbacSeeded();
|
|
86
|
+
expect(await countBuiltinPermissions()).toBe(seeded);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a failed sync does not mark the handle done", async () => {
|
|
90
|
+
// Make the built-in role unrecoverable: its name is taken by another role id.
|
|
91
|
+
const builtin = BUILTIN_ROLES.find((role) => role.id === BUILTIN_ROLE_ID);
|
|
92
|
+
if (!builtin) throw new Error("test fixture: built-in role missing");
|
|
93
|
+
const client = getDbClient();
|
|
94
|
+
await client.run("PRAGMA foreign_keys = OFF");
|
|
95
|
+
await client.run("DELETE FROM roles WHERE id = ?", [BUILTIN_ROLE_ID]);
|
|
96
|
+
await client.run("PRAGMA foreign_keys = ON");
|
|
97
|
+
await client.run(
|
|
98
|
+
"INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES ('rbac-role-collision', ?, 'x', 0, 0)",
|
|
99
|
+
[builtin.name],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(() => ensureRbacSeeded()).toThrow(/already uses that name/);
|
|
103
|
+
// Not cached as done: the next caller retries and sees the same error.
|
|
104
|
+
expect(() => ensureRbacSeeded()).toThrow(/already uses that name/);
|
|
105
|
+
|
|
106
|
+
await client.run("DELETE FROM roles WHERE id = 'rbac-role-collision'");
|
|
107
|
+
ensureRbacSeeded();
|
|
108
|
+
expect(await countBuiltinPermissions()).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("createServer (one call per MCP session)", () => {
|
|
113
|
+
let savedDatabasePath: string | undefined;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
savedDatabasePath = process.env.DATABASE_PATH;
|
|
117
|
+
process.env.DATABASE_PATH = TEST_DB_PATH;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
if (savedDatabasePath === undefined) delete process.env.DATABASE_PATH;
|
|
122
|
+
else process.env.DATABASE_PATH = savedDatabasePath;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("seeds pricing and RBAC on the first session only", async () => {
|
|
126
|
+
await createServer();
|
|
127
|
+
expect(await countPricingSeedRows()).toBeGreaterThan(0);
|
|
128
|
+
expect(await countBuiltinPermissions()).toBeGreaterThan(0);
|
|
129
|
+
|
|
130
|
+
await getDbClient().run("DELETE FROM pricing WHERE effective_from = 0");
|
|
131
|
+
await getDbClient().run("DELETE FROM role_permissions WHERE roleId = ?", [BUILTIN_ROLE_ID]);
|
|
132
|
+
|
|
133
|
+
// A later session re-registers tools but must not re-run the seeds.
|
|
134
|
+
const second = await createServer();
|
|
135
|
+
expect(second).toBeDefined();
|
|
136
|
+
expect(await countPricingSeedRows()).toBe(0);
|
|
137
|
+
expect(await countBuiltinPermissions()).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("loadModelsDevCache", () => {
|
|
142
|
+
const saved = process.env.MODELSDEV_CACHE_PATH;
|
|
143
|
+
let dir: string | undefined;
|
|
144
|
+
|
|
145
|
+
afterEach(async () => {
|
|
146
|
+
if (saved === undefined) delete process.env.MODELSDEV_CACHE_PATH;
|
|
147
|
+
else process.env.MODELSDEV_CACHE_PATH = saved;
|
|
148
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
149
|
+
dir = undefined;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("returns the same parsed object while it is alive", () => {
|
|
153
|
+
const first = loadModelsDevCache();
|
|
154
|
+
expect(first).not.toBeNull();
|
|
155
|
+
expect(loadModelsDevCache()).toBe(first);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("keys the memo on the resolved path", async () => {
|
|
159
|
+
dir = await mkdtemp(path.join(tmpdir(), "modelsdev-cache-"));
|
|
160
|
+
const alt = path.join(dir, "cache.json");
|
|
161
|
+
await writeFile(alt, JSON.stringify({ anthropic: { id: "anthropic-alt", models: {} } }));
|
|
162
|
+
|
|
163
|
+
const bundled = loadModelsDevCache();
|
|
164
|
+
process.env.MODELSDEV_CACHE_PATH = alt;
|
|
165
|
+
const overridden = loadModelsDevCache();
|
|
166
|
+
|
|
167
|
+
expect(overridden?.anthropic?.id).toBe("anthropic-alt");
|
|
168
|
+
expect(overridden).not.toBe(bundled);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createApprovalRequest,
|
|
8
|
+
createTaskExtended,
|
|
9
|
+
getApprovalRequestById,
|
|
10
|
+
getDbClient,
|
|
11
|
+
initDb,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { registerCancelApprovalRequestTool } from "../tools/cancel-approval-request";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-cancel-approval-request-tool.sqlite";
|
|
16
|
+
|
|
17
|
+
type ToolResult = {
|
|
18
|
+
isError?: boolean;
|
|
19
|
+
structuredContent: { success: boolean; alreadyCancelled?: boolean; runCancelled?: boolean };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let callTool: (args: unknown, agentId: string) => Promise<ToolResult>;
|
|
23
|
+
let leadId = "";
|
|
24
|
+
let ownerId = "";
|
|
25
|
+
let workerId = "";
|
|
26
|
+
|
|
27
|
+
async function makeRequest() {
|
|
28
|
+
const sourceTask = await createTaskExtended("source", { agentId: ownerId, source: "mcp" });
|
|
29
|
+
const id = crypto.randomUUID();
|
|
30
|
+
await createApprovalRequest({
|
|
31
|
+
id,
|
|
32
|
+
title: "Tool cancel",
|
|
33
|
+
questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
|
|
34
|
+
approvers: { policy: "any" },
|
|
35
|
+
sourceTaskId: sourceTask.id,
|
|
36
|
+
});
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("cancel-approval-request tool", () => {
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
initDb(TEST_DB_PATH);
|
|
43
|
+
leadId = (await createAgent({ name: "tool-lead", isLead: true, status: "idle" })).id;
|
|
44
|
+
ownerId = (await createAgent({ name: "tool-owner", isLead: false, status: "idle" })).id;
|
|
45
|
+
workerId = (await createAgent({ name: "tool-worker", isLead: false, status: "idle" })).id;
|
|
46
|
+
const server = new McpServer({ name: "cancel-approval-request-test", version: "1.0.0" });
|
|
47
|
+
registerCancelApprovalRequestTool(server);
|
|
48
|
+
const tool = (
|
|
49
|
+
server as unknown as {
|
|
50
|
+
_registeredTools: Record<
|
|
51
|
+
string,
|
|
52
|
+
{ handler: (args: unknown, extra: unknown) => Promise<unknown> }
|
|
53
|
+
>;
|
|
54
|
+
}
|
|
55
|
+
)._registeredTools["cancel-approval-request"];
|
|
56
|
+
if (!tool) throw new Error("cancel-approval-request tool was not registered");
|
|
57
|
+
callTool = async (args, agentId) =>
|
|
58
|
+
(await tool.handler(args, {
|
|
59
|
+
sessionId: "cancel-approval-request-test",
|
|
60
|
+
requestInfo: { headers: { "x-agent-id": agentId } },
|
|
61
|
+
})) as ToolResult;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
closeDb();
|
|
66
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
67
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a lead cancels a pending request, then a second call is a no-op", async () => {
|
|
72
|
+
const requestId = await makeRequest();
|
|
73
|
+
|
|
74
|
+
const first = await callTool({ requestId, reason: "not needed" }, leadId);
|
|
75
|
+
expect(first.isError).toBeFalsy();
|
|
76
|
+
expect(first.structuredContent.alreadyCancelled).toBe(false);
|
|
77
|
+
const row = await getApprovalRequestById(requestId);
|
|
78
|
+
expect(row!.status).toBe("cancelled");
|
|
79
|
+
expect(row!.resolvedBy).toBe(leadId);
|
|
80
|
+
expect(row!.resolutionReason).toBe("not needed");
|
|
81
|
+
|
|
82
|
+
const second = await callTool({ requestId }, leadId);
|
|
83
|
+
expect(second.isError).toBeFalsy();
|
|
84
|
+
expect(second.structuredContent.alreadyCancelled).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a worker that does not own the source task gets a tool error", async () => {
|
|
88
|
+
const requestId = await makeRequest();
|
|
89
|
+
|
|
90
|
+
const result = await callTool({ requestId }, workerId);
|
|
91
|
+
|
|
92
|
+
expect(result.isError).toBe(true);
|
|
93
|
+
expect((await getApprovalRequestById(requestId))!.status).toBe("pending");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the owner agent may cancel", async () => {
|
|
97
|
+
const requestId = await makeRequest();
|
|
98
|
+
const result = await callTool({ requestId }, ownerId);
|
|
99
|
+
expect(result.isError).toBeFalsy();
|
|
100
|
+
expect((await getApprovalRequestById(requestId))!.status).toBe("cancelled");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an expired pending request gets a tool error and becomes timeout", async () => {
|
|
104
|
+
const requestId = await makeRequest();
|
|
105
|
+
await getDbClient().run("UPDATE approval_requests SET expiresAt = ? WHERE id = ?", [
|
|
106
|
+
new Date(Date.now() - 60_000).toISOString(),
|
|
107
|
+
requestId,
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const result = await callTool({ requestId }, leadId);
|
|
111
|
+
|
|
112
|
+
expect(result.isError).toBe(true);
|
|
113
|
+
expect((await getApprovalRequestById(requestId))!.status).toBe("timeout");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -922,6 +922,12 @@ describe("ClaudeManagedAdapter (Phase 4) — repo provisioning + cost data", ()
|
|
|
922
922
|
cacheReadPerMillion: 1.0,
|
|
923
923
|
cacheWritePerMillion: 12.5,
|
|
924
924
|
});
|
|
925
|
+
expect(CLAUDE_MANAGED_MODEL_PRICING["claude-sonnet-5-5"]).toEqual({
|
|
926
|
+
inputPerMillion: 2.0,
|
|
927
|
+
outputPerMillion: 10.0,
|
|
928
|
+
cacheReadPerMillion: 0.2,
|
|
929
|
+
cacheWritePerMillion: 2.5,
|
|
930
|
+
});
|
|
925
931
|
expect(CLAUDE_MANAGED_MODEL_PRICING["claude-sonnet-5"]).toEqual({
|
|
926
932
|
inputPerMillion: 2.0,
|
|
927
933
|
outputPerMillion: 10.0,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { closeDb, initDb, upsertSwarmConfig } from "../be/db";
|
|
4
|
+
import { handleConfig } from "../http/config";
|
|
5
|
+
import { handleCore } from "../http/core";
|
|
6
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
7
|
+
import { listenOnFreePort } from "./test-net";
|
|
8
|
+
|
|
9
|
+
// ctx.swarm.config_get / config_list send `key` to these routes. Both used to
|
|
10
|
+
// ignore it and return every row, so a script asking for one secret got all.
|
|
11
|
+
|
|
12
|
+
const API_KEY = "example-config-key-filter-test-key";
|
|
13
|
+
|
|
14
|
+
let server: Server;
|
|
15
|
+
let baseUrl: string;
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
initDb(":memory:");
|
|
19
|
+
await upsertSwarmConfig({ scope: "global", key: "WANTED_KEY", value: "wanted" });
|
|
20
|
+
await upsertSwarmConfig({
|
|
21
|
+
scope: "global",
|
|
22
|
+
key: "OTHER_SECRET",
|
|
23
|
+
value: "other-secret-value",
|
|
24
|
+
isSecret: true,
|
|
25
|
+
});
|
|
26
|
+
await upsertSwarmConfig({ scope: "global", key: "PLAIN_KEY", value: "plain" });
|
|
27
|
+
|
|
28
|
+
server = createServer(async (req, res) => {
|
|
29
|
+
if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, API_KEY)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const segments = getPathSegments(req.url || "");
|
|
33
|
+
const query = parseQueryParams(req.url || "");
|
|
34
|
+
if (await handleConfig(req, res, segments, query)) return;
|
|
35
|
+
res.writeHead(404).end();
|
|
36
|
+
});
|
|
37
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
42
|
+
closeDb();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function getKeys(path: string): Promise<string[]> {
|
|
46
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
47
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
48
|
+
});
|
|
49
|
+
expect(response.status).toBe(200);
|
|
50
|
+
const body = (await response.json()) as { configs: Array<{ key: string }> };
|
|
51
|
+
return body.configs.map((config) => config.key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALL_KEYS = ["OTHER_SECRET", "PLAIN_KEY", "WANTED_KEY"];
|
|
55
|
+
|
|
56
|
+
describe.each([
|
|
57
|
+
["/api/config/resolved", "?"],
|
|
58
|
+
["/api/config", "?scope=global&"],
|
|
59
|
+
])("GET %s key filter", (path, prefix) => {
|
|
60
|
+
test("?key=<existing> returns exactly that row", async () => {
|
|
61
|
+
expect(await getKeys(`${path}${prefix}key=WANTED_KEY&includeSecrets=true`)).toEqual([
|
|
62
|
+
"WANTED_KEY",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("?key=<unknown> returns an empty list", async () => {
|
|
67
|
+
expect(await getKeys(`${path}${prefix}key=NO_SUCH_KEY&includeSecrets=true`)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no key returns every row", async () => {
|
|
71
|
+
expect((await getKeys(`${path}${prefix}includeSecrets=true`)).sort()).toEqual(ALL_KEYS);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an empty key is rejected instead of returning every row", async () => {
|
|
75
|
+
const response = await fetch(`${baseUrl}${path}${prefix}key=`, {
|
|
76
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
77
|
+
});
|
|
78
|
+
expect(response.status).toBe(400);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -27,6 +27,7 @@ describe("getContextWindowSize", () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
test("returns 1M for sonnet models", () => {
|
|
30
|
+
expect(getContextWindowSize("claude-sonnet-5-5")).toBe(1_000_000);
|
|
30
31
|
expect(getContextWindowSize("claude-sonnet-5")).toBe(1_000_000);
|
|
31
32
|
expect(getContextWindowSize("claude-sonnet-4-6")).toBe(1_000_000);
|
|
32
33
|
expect(getContextWindowSize("sonnet")).toBe(1_000_000);
|
|
@@ -202,6 +202,102 @@ describe("db-client SQLITE_BUSY retry", () => {
|
|
|
202
202
|
expect(Date.now() - started).toBeLessThan(500);
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
describe("under the production 5000ms ambient busy_timeout", () => {
|
|
206
|
+
/** Largest gap between 5ms ticks while `work` runs: the longest the loop was frozen. */
|
|
207
|
+
async function maxLoopFreezeMs(work: () => Promise<unknown>): Promise<number> {
|
|
208
|
+
let last = performance.now();
|
|
209
|
+
let maxGap = 0;
|
|
210
|
+
const ticker = setInterval(() => {
|
|
211
|
+
const now = performance.now();
|
|
212
|
+
maxGap = Math.max(maxGap, now - last);
|
|
213
|
+
last = now;
|
|
214
|
+
}, 5);
|
|
215
|
+
try {
|
|
216
|
+
await work();
|
|
217
|
+
// One more tick so a freeze that ended on the last attempt is seen.
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
219
|
+
} finally {
|
|
220
|
+
clearInterval(ticker);
|
|
221
|
+
}
|
|
222
|
+
return maxGap;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
main.exec("PRAGMA busy_timeout = 5000");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a contended write still succeeds via the async retry", async () => {
|
|
230
|
+
const released = holdWriteLock(300);
|
|
231
|
+
const freezeMs = await maxLoopFreezeMs(() =>
|
|
232
|
+
client.run("INSERT INTO items (name) VALUES (?)", ["contended"]),
|
|
233
|
+
);
|
|
234
|
+
await released;
|
|
235
|
+
const landed = await client.get<{ name: string }>(
|
|
236
|
+
"SELECT name FROM items WHERE name = 'contended'",
|
|
237
|
+
);
|
|
238
|
+
expect(landed?.name).toBe("contended");
|
|
239
|
+
// Every attempt spins at most attemptSpinMs (25ms) or finalAttemptSpinMs;
|
|
240
|
+
// the release happened during an async backoff sleep.
|
|
241
|
+
expect(freezeMs).toBeLessThan(1_000);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("the final statement attempt spins finalAttemptSpinMs, not the ambient timeout", async () => {
|
|
245
|
+
const tight = createBunSqliteClient(() => main, {
|
|
246
|
+
maxWaitMs: 60,
|
|
247
|
+
backoffMs: [20, 20],
|
|
248
|
+
attemptSpinMs: 25,
|
|
249
|
+
finalAttemptSpinMs: 50,
|
|
250
|
+
});
|
|
251
|
+
// The holder releases on a timer, which cannot fire while the final
|
|
252
|
+
// attempt blocks the loop: pre-fix this froze for the full 5000ms.
|
|
253
|
+
const released = holdWriteLock(3_000);
|
|
254
|
+
let caught: unknown;
|
|
255
|
+
const startedAt = performance.now();
|
|
256
|
+
const freezeMs = await maxLoopFreezeMs(async () => {
|
|
257
|
+
try {
|
|
258
|
+
await tight.run("INSERT INTO items (name) VALUES (?)", ["never"]);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
caught = err;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
const wallMs = performance.now() - startedAt;
|
|
264
|
+
await released;
|
|
265
|
+
expect((caught as { code?: string }).code).toBe("SQLITE_BUSY");
|
|
266
|
+
expect(wallMs).toBeLessThan(2_000);
|
|
267
|
+
expect(freezeMs).toBeLessThan(1_000);
|
|
268
|
+
const row = main.query<{ timeout: number }, []>("PRAGMA busy_timeout").get();
|
|
269
|
+
expect(row?.timeout).toBe(5000);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the final BEGIN IMMEDIATE attempt spins finalAttemptSpinMs, not the ambient timeout", async () => {
|
|
273
|
+
const tight = createBunSqliteClient(() => main, {
|
|
274
|
+
maxWaitMs: 60,
|
|
275
|
+
backoffMs: [20, 20],
|
|
276
|
+
attemptSpinMs: 25,
|
|
277
|
+
finalAttemptSpinMs: 50,
|
|
278
|
+
});
|
|
279
|
+
const released = holdWriteLock(3_000);
|
|
280
|
+
let caught: unknown;
|
|
281
|
+
const startedAt = performance.now();
|
|
282
|
+
const freezeMs = await maxLoopFreezeMs(async () => {
|
|
283
|
+
try {
|
|
284
|
+
await tight.transaction(async (tx) => {
|
|
285
|
+
await tx.run("INSERT INTO items (name) VALUES (?)", ["never-tx"]);
|
|
286
|
+
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
caught = err;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
const wallMs = performance.now() - startedAt;
|
|
292
|
+
await released;
|
|
293
|
+
expect((caught as { code?: string }).code).toBe("SQLITE_BUSY");
|
|
294
|
+
expect(wallMs).toBeLessThan(2_000);
|
|
295
|
+
expect(freezeMs).toBeLessThan(1_000);
|
|
296
|
+
const row = main.query<{ timeout: number }, []>("PRAGMA busy_timeout").get();
|
|
297
|
+
expect(row?.timeout).toBe(5000);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
205
301
|
test("reads proceed under an external write lock without needing the retry path", async () => {
|
|
206
302
|
await client.run("INSERT INTO items (name) VALUES (?)", ["pre-existing"]);
|
|
207
303
|
const released = holdWriteLock(150);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -427,3 +427,62 @@ describe("db-client transactions on a shared WAL file (second connection)", () =
|
|
|
427
427
|
expect(row?.n).toBe(11);
|
|
428
428
|
});
|
|
429
429
|
});
|
|
430
|
+
|
|
431
|
+
describe("db-client slow statement warning", () => {
|
|
432
|
+
// A recursive CTE is pure CPU inside one driver call, so it blocks the
|
|
433
|
+
// event loop the same way a slow production query does.
|
|
434
|
+
const SLOW_SQL = `/* ${"padding ".repeat(40)} */ WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < ?) SELECT count(*) AS n FROM c WHERE ? IS NOT NULL`;
|
|
435
|
+
const SLOW_ROWS = 3_000_000;
|
|
436
|
+
const PARAM_SECRET = "param-value-that-must-not-be-logged";
|
|
437
|
+
|
|
438
|
+
const slowWarnings = (spy: ReturnType<typeof spyOn>): string[] =>
|
|
439
|
+
spy.mock.calls
|
|
440
|
+
.map((args) => String(args[0]))
|
|
441
|
+
.filter((line) => line.includes("[db-client] slow sync statement"));
|
|
442
|
+
|
|
443
|
+
test("logs a statement that blocked the loop past 100ms, with truncated SQL and no params", async () => {
|
|
444
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
445
|
+
try {
|
|
446
|
+
const startedAt = performance.now();
|
|
447
|
+
const row = await client.get<{ n: number }>(SLOW_SQL, [SLOW_ROWS, PARAM_SECRET]);
|
|
448
|
+
const elapsedMs = performance.now() - startedAt;
|
|
449
|
+
expect(row?.n).toBe(SLOW_ROWS);
|
|
450
|
+
// Guard the premise: on a machine fast enough to finish under the
|
|
451
|
+
// threshold, the warning is correctly absent and this test says so.
|
|
452
|
+
expect(elapsedMs).toBeGreaterThan(100);
|
|
453
|
+
const lines = slowWarnings(warn);
|
|
454
|
+
expect(lines).toHaveLength(1);
|
|
455
|
+
expect(lines[0]).toMatch(/blocked the event loop for \d+ms: \/\* padding/);
|
|
456
|
+
expect(lines[0]).not.toContain(PARAM_SECRET);
|
|
457
|
+
expect(lines[0]).not.toContain("WITH RECURSIVE");
|
|
458
|
+
expect(lines[0]?.endsWith("…")).toBe(true);
|
|
459
|
+
} finally {
|
|
460
|
+
warn.mockRestore();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("logs a slow statement run inside a transaction", async () => {
|
|
465
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
466
|
+
try {
|
|
467
|
+
await client.transaction(async (tx) => {
|
|
468
|
+
await tx.get(SLOW_SQL, [SLOW_ROWS, PARAM_SECRET]);
|
|
469
|
+
});
|
|
470
|
+
const lines = slowWarnings(warn);
|
|
471
|
+
expect(lines).toHaveLength(1);
|
|
472
|
+
expect(lines[0]).not.toContain(PARAM_SECRET);
|
|
473
|
+
} finally {
|
|
474
|
+
warn.mockRestore();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("does not log fast statements", async () => {
|
|
479
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
480
|
+
try {
|
|
481
|
+
await client.run("INSERT INTO items (name) VALUES (?)", ["fast"]);
|
|
482
|
+
await client.query("SELECT name FROM items");
|
|
483
|
+
expect(slowWarnings(warn)).toHaveLength(0);
|
|
484
|
+
} finally {
|
|
485
|
+
warn.mockRestore();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|