@desplega.ai/agent-swarm 1.156.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-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
- package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
- package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
- package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
- package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
- package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
- package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
- package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
- package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
- package/dist/{claude-adapter-112w28qc.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-k7pb5kfz.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-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
- package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
- package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
- package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
- package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
- package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
- package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
- package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
- package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
- package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
- package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
- package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
- package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
- package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
- package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
- package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
- package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
- package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
- package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
- package/dist/{cli-7vj52450.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-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
- package/dist/{cli-s8hpxeg4.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-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
- package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
- package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
- package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
- package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
- package/dist/cli.js +30 -16
- package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
- package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
- package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
- package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
- package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
- package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
- package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
- package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
- package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
- package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
- package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
- package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
- package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
- package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
- package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
- package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
- package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
- package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
- package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
- package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
- package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
- package/dist/{otel-impl-jgdfm26f.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-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
- package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
- package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
- package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
- package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
- package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
- package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
- package/openapi.json +1626 -629
- package/package.json +3 -2
- 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/codex-agents-md.ts +140 -17
- 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/deferred-task-waits.ts +28 -0
- package/src/scheduler/schedule-task.ts +3 -0
- package/src/scheduler/scheduler.ts +14 -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/codex-adapter.test.ts +126 -1
- 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/deferred-schedule-reaper.test.ts +193 -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/schedules/list-schedules.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/templates/skills/scheduling/content.md +1 -1
- package/dist/cli-2daksqps.js +0 -378
- package/dist/rbac-roles-3zw68x72.js +0 -37
|
@@ -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
|
+
});
|
|
@@ -41,6 +41,7 @@ const baseFacade = `type:AgentMailInboxMapping
|
|
|
41
41
|
type:ApiKeyStatus
|
|
42
42
|
type:AppVersion
|
|
43
43
|
type:ApprovalRequest
|
|
44
|
+
type:ApprovalRequestSummary
|
|
44
45
|
type:AssetSummaryFilters
|
|
45
46
|
type:AttributionByPersonRow
|
|
46
47
|
type:AvailableKeyIndicesResult
|
|
@@ -64,6 +65,7 @@ type:InboxSummary
|
|
|
64
65
|
type:InsertPricingRowInput
|
|
65
66
|
type:InsertTaskAttachmentInput
|
|
66
67
|
type:KeyCostSummary
|
|
68
|
+
type:KvJsonFieldEquals
|
|
67
69
|
type:McpServerFilters
|
|
68
70
|
type:McpServerInsert
|
|
69
71
|
type:MentionPreview
|
|
@@ -113,6 +115,7 @@ value:autoEncryptLegacyPlaintextSecrets
|
|
|
113
115
|
value:backfillSupersedeTaskResumeTaskId
|
|
114
116
|
value:bindSlackMessageTimestamp
|
|
115
117
|
value:buildRoutingAffinityFromAgent
|
|
118
|
+
value:cancelApprovalRequestById
|
|
116
119
|
value:cancelPendingApprovalRequestsForRun
|
|
117
120
|
value:cancelPendingSteeringForTask
|
|
118
121
|
value:cancelTask
|
|
@@ -223,6 +226,7 @@ value:getActiveSessions
|
|
|
223
226
|
value:getActiveTaskCount
|
|
224
227
|
value:getAgentById
|
|
225
228
|
value:getAgentCurrentTask
|
|
229
|
+
value:getAgentDailyTaskCounts
|
|
226
230
|
value:getAgentHarnessProviders
|
|
227
231
|
value:getAgentMailInboxMapping
|
|
228
232
|
value:getAgentMailInboxMappingsByAgent
|
|
@@ -265,6 +269,7 @@ value:getContextSnapshotsByTaskId
|
|
|
265
269
|
value:getContextSummaryByTaskId
|
|
266
270
|
value:getContextVersion
|
|
267
271
|
value:getContextVersionHistory
|
|
272
|
+
value:getCurrentStepForNode
|
|
268
273
|
value:getDailySpendForAgent
|
|
269
274
|
value:getDailySpendForUser
|
|
270
275
|
value:getDailySpendGlobal
|
|
@@ -370,6 +375,7 @@ value:getSlackTasksMissingTree
|
|
|
370
375
|
value:getSlackTreeMessage
|
|
371
376
|
value:getSlackTreeMessageByThread
|
|
372
377
|
value:getSlackTreeMessages
|
|
378
|
+
value:getStaleApprovalRequests
|
|
373
379
|
value:getStalePinnedResumes
|
|
374
380
|
value:getStaleUnassignedAffinityTasks
|
|
375
381
|
value:getStalledInProgressTasks
|
|
@@ -380,6 +386,7 @@ value:getStepCountForNode
|
|
|
380
386
|
value:getStuckApprovalRuns
|
|
381
387
|
value:getStuckWaitRuns
|
|
382
388
|
value:getStuckWorkflowRuns
|
|
389
|
+
value:getSupersededTasksWithoutResume
|
|
383
390
|
value:getSwarmConfigById
|
|
384
391
|
value:getSwarmConfigLookupById
|
|
385
392
|
value:getSwarmConfigs
|
|
@@ -436,9 +443,11 @@ value:isPendingSlackMessage
|
|
|
436
443
|
value:isPoolAffinityEnforcementEnabled
|
|
437
444
|
value:isSettledSlackMessage
|
|
438
445
|
value:isSqliteVecAvailable
|
|
446
|
+
value:kvPrefixUpperBound
|
|
439
447
|
value:listAgentsWithCredStatusByProvider
|
|
440
448
|
value:listAllMetrics
|
|
441
449
|
value:listAllPages
|
|
450
|
+
value:listApprovalRequestSummaries
|
|
442
451
|
value:listApprovalRequests
|
|
443
452
|
value:listAssetSummaries
|
|
444
453
|
value:listCancelledApprovalRequestsForRun
|
|
@@ -595,6 +604,7 @@ value:getMostRecentTaskInThread
|
|
|
595
604
|
value:getPendingSlackRelayTasks
|
|
596
605
|
value:getRecentlyFinishedWorkerTasks
|
|
597
606
|
value:getSlackTasksMissingTree
|
|
607
|
+
value:getSupersededTasksWithoutResume
|
|
598
608
|
value:getTaskById
|
|
599
609
|
value:getTaskStats
|
|
600
610
|
value:getTasksByAgentId
|
|
@@ -42,6 +42,7 @@ const baseFacade = `type:AgentMailInboxMapping
|
|
|
42
42
|
type:ApiKeyStatus
|
|
43
43
|
type:AppVersion
|
|
44
44
|
type:ApprovalRequest
|
|
45
|
+
type:ApprovalRequestSummary
|
|
45
46
|
type:AssetSummaryFilters
|
|
46
47
|
type:AttributionByPersonRow
|
|
47
48
|
type:AvailableKeyIndicesResult
|
|
@@ -65,6 +66,7 @@ type:InboxSummary
|
|
|
65
66
|
type:InsertPricingRowInput
|
|
66
67
|
type:InsertTaskAttachmentInput
|
|
67
68
|
type:KeyCostSummary
|
|
69
|
+
type:KvJsonFieldEquals
|
|
68
70
|
type:McpServerFilters
|
|
69
71
|
type:McpServerInsert
|
|
70
72
|
type:MentionPreview
|
|
@@ -114,6 +116,7 @@ value:autoEncryptLegacyPlaintextSecrets
|
|
|
114
116
|
value:backfillSupersedeTaskResumeTaskId
|
|
115
117
|
value:bindSlackMessageTimestamp
|
|
116
118
|
value:buildRoutingAffinityFromAgent
|
|
119
|
+
value:cancelApprovalRequestById
|
|
117
120
|
value:cancelPendingApprovalRequestsForRun
|
|
118
121
|
value:cancelPendingSteeringForTask
|
|
119
122
|
value:cancelTask
|
|
@@ -224,6 +227,7 @@ value:getActiveSessions
|
|
|
224
227
|
value:getActiveTaskCount
|
|
225
228
|
value:getAgentById
|
|
226
229
|
value:getAgentCurrentTask
|
|
230
|
+
value:getAgentDailyTaskCounts
|
|
227
231
|
value:getAgentHarnessProviders
|
|
228
232
|
value:getAgentMailInboxMapping
|
|
229
233
|
value:getAgentMailInboxMappingsByAgent
|
|
@@ -266,6 +270,7 @@ value:getContextSnapshotsByTaskId
|
|
|
266
270
|
value:getContextSummaryByTaskId
|
|
267
271
|
value:getContextVersion
|
|
268
272
|
value:getContextVersionHistory
|
|
273
|
+
value:getCurrentStepForNode
|
|
269
274
|
value:getDailySpendForAgent
|
|
270
275
|
value:getDailySpendForUser
|
|
271
276
|
value:getDailySpendGlobal
|
|
@@ -371,6 +376,7 @@ value:getSlackTasksMissingTree
|
|
|
371
376
|
value:getSlackTreeMessage
|
|
372
377
|
value:getSlackTreeMessageByThread
|
|
373
378
|
value:getSlackTreeMessages
|
|
379
|
+
value:getStaleApprovalRequests
|
|
374
380
|
value:getStalePinnedResumes
|
|
375
381
|
value:getStaleUnassignedAffinityTasks
|
|
376
382
|
value:getStalledInProgressTasks
|
|
@@ -381,6 +387,7 @@ value:getStepCountForNode
|
|
|
381
387
|
value:getStuckApprovalRuns
|
|
382
388
|
value:getStuckWaitRuns
|
|
383
389
|
value:getStuckWorkflowRuns
|
|
390
|
+
value:getSupersededTasksWithoutResume
|
|
384
391
|
value:getSwarmConfigById
|
|
385
392
|
value:getSwarmConfigLookupById
|
|
386
393
|
value:getSwarmConfigs
|
|
@@ -437,9 +444,11 @@ value:isPendingSlackMessage
|
|
|
437
444
|
value:isPoolAffinityEnforcementEnabled
|
|
438
445
|
value:isSettledSlackMessage
|
|
439
446
|
value:isSqliteVecAvailable
|
|
447
|
+
value:kvPrefixUpperBound
|
|
440
448
|
value:listAgentsWithCredStatusByProvider
|
|
441
449
|
value:listAllMetrics
|
|
442
450
|
value:listAllPages
|
|
451
|
+
value:listApprovalRequestSummaries
|
|
443
452
|
value:listApprovalRequests
|
|
444
453
|
value:listAssetSummaries
|
|
445
454
|
value:listCancelledApprovalRequestsForRun
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spent deferral timers are reaped once they age past the retention window,
|
|
3
|
+
* whether the ceiling fired them or a watched task's settlement woke the
|
|
4
|
+
* waiter early. The wake-up task keeps its `scheduleId` and stays readable.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { unlink } from "node:fs/promises";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import {
|
|
10
|
+
closeDb,
|
|
11
|
+
completeTask,
|
|
12
|
+
createAgent,
|
|
13
|
+
createScheduledTask,
|
|
14
|
+
createTaskExtended,
|
|
15
|
+
getDbClient,
|
|
16
|
+
getScheduledTaskById,
|
|
17
|
+
getTaskById,
|
|
18
|
+
initDb,
|
|
19
|
+
startTask,
|
|
20
|
+
updateScheduledTask,
|
|
21
|
+
} from "../be/db";
|
|
22
|
+
import {
|
|
23
|
+
reapSpentDeferredSchedules,
|
|
24
|
+
reconcileDeferredTaskWaits,
|
|
25
|
+
SPENT_DEFERRED_SCHEDULE_RETENTION_MS,
|
|
26
|
+
} from "../scheduler/deferred-task-waits";
|
|
27
|
+
import { executeSchedule } from "../scheduler/scheduler";
|
|
28
|
+
import { registerDeferTaskTool } from "../tools/defer-task";
|
|
29
|
+
|
|
30
|
+
const TEST_DB_PATH = "./test-deferred-schedule-reaper.sqlite";
|
|
31
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
type Result = { structuredContent: { success: boolean; message: string; scheduleId?: string } };
|
|
34
|
+
type Tool = { handler: (args: unknown, extra: unknown) => Promise<Result> };
|
|
35
|
+
|
|
36
|
+
let agentId: string;
|
|
37
|
+
let tool: Tool;
|
|
38
|
+
|
|
39
|
+
async function removeDbFiles() {
|
|
40
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
41
|
+
try {
|
|
42
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
await removeDbFiles();
|
|
49
|
+
initDb(TEST_DB_PATH);
|
|
50
|
+
agentId = (
|
|
51
|
+
await createAgent({
|
|
52
|
+
name: "Reap worker",
|
|
53
|
+
isLead: false,
|
|
54
|
+
status: "busy",
|
|
55
|
+
capabilities: [],
|
|
56
|
+
maxTasks: 10,
|
|
57
|
+
})
|
|
58
|
+
).id;
|
|
59
|
+
const server = new McpServer({ name: "reap-test", version: "1.0.0" });
|
|
60
|
+
registerDeferTaskTool(server);
|
|
61
|
+
tool = (server as unknown as { _registeredTools: Record<string, Tool> })._registeredTools[
|
|
62
|
+
"defer-task"
|
|
63
|
+
]!;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
closeDb();
|
|
68
|
+
await removeDbFiles();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function activeTask() {
|
|
72
|
+
const task = await createTaskExtended("work in progress", { agentId });
|
|
73
|
+
await startTask(task.id);
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function defer(taskId: string, extra: Record<string, unknown> = {}) {
|
|
78
|
+
const result = await tool.handler(
|
|
79
|
+
{ taskId, delayMs: 3600000, summary: "Submitted", note: "Check the result", ...extra },
|
|
80
|
+
{ sessionId: crypto.randomUUID(), requestInfo: { headers: { "x-agent-id": agentId } } },
|
|
81
|
+
);
|
|
82
|
+
expect(result.structuredContent.success).toBe(true);
|
|
83
|
+
return (await getScheduledTaskById(result.structuredContent.scheduleId!))!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function wakeTask(scheduleId: string) {
|
|
87
|
+
const row = await getDbClient().get<{ id: string }>(
|
|
88
|
+
"SELECT id FROM agent_tasks WHERE scheduleId = ?",
|
|
89
|
+
[scheduleId],
|
|
90
|
+
);
|
|
91
|
+
return row ? getTaskById(row.id) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function waitRows(scheduleId: string) {
|
|
95
|
+
const row = await getDbClient().get<{ waits: number; members: number }>(
|
|
96
|
+
`SELECT (SELECT COUNT(*) FROM deferred_task_waits WHERE scheduleId = ?) AS waits,
|
|
97
|
+
(SELECT COUNT(*) FROM deferred_task_wait_members WHERE scheduleId = ?) AS members`,
|
|
98
|
+
[scheduleId, scheduleId],
|
|
99
|
+
);
|
|
100
|
+
return row!;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `now` just inside and just past the retention window, measured from lastRunAt. */
|
|
104
|
+
async function horizons(scheduleId: string) {
|
|
105
|
+
const lastRunAt = (await getScheduledTaskById(scheduleId))!.lastRunAt!;
|
|
106
|
+
const firedAt = new Date(lastRunAt).getTime();
|
|
107
|
+
return {
|
|
108
|
+
inside: new Date(firedAt + SPENT_DEFERRED_SCHEDULE_RETENTION_MS - DAY_MS),
|
|
109
|
+
past: new Date(firedAt + SPENT_DEFERRED_SCHEDULE_RETENTION_MS + DAY_MS),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("reapSpentDeferredSchedules", () => {
|
|
114
|
+
test("timer fires: the spent row is kept inside the window and deleted past it", async () => {
|
|
115
|
+
const parent = await activeTask();
|
|
116
|
+
const schedule = await defer(parent.id);
|
|
117
|
+
await executeSchedule(schedule);
|
|
118
|
+
|
|
119
|
+
const fired = (await getScheduledTaskById(schedule.id))!;
|
|
120
|
+
expect(fired.enabled).toBe(false);
|
|
121
|
+
expect(fired.lastRunAt).toBeDefined();
|
|
122
|
+
const wake = await wakeTask(schedule.id);
|
|
123
|
+
expect(wake?.parentTaskId).toBe(parent.id);
|
|
124
|
+
|
|
125
|
+
const { inside, past } = await horizons(schedule.id);
|
|
126
|
+
await reapSpentDeferredSchedules(inside);
|
|
127
|
+
expect(await getScheduledTaskById(schedule.id)).not.toBeNull();
|
|
128
|
+
|
|
129
|
+
expect(await reapSpentDeferredSchedules(past)).toBeGreaterThanOrEqual(1);
|
|
130
|
+
expect(await getScheduledTaskById(schedule.id)).toBeNull();
|
|
131
|
+
// The wake-up task survives with its provenance id intact.
|
|
132
|
+
const after = await getTaskById(wake!.id);
|
|
133
|
+
expect(after?.scheduleId).toBe(schedule.id);
|
|
134
|
+
expect(after?.parentTaskId).toBe(parent.id);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("settlement wakes early: the disabled timer is reaped and its wait cascades", async () => {
|
|
138
|
+
const parent = await activeTask();
|
|
139
|
+
const producer = await activeTask();
|
|
140
|
+
const schedule = await defer(parent.id, {
|
|
141
|
+
wakeOn: { taskId: producer.id, event: "settled" },
|
|
142
|
+
});
|
|
143
|
+
expect(await waitRows(schedule.id)).toEqual({ waits: 1, members: 1 });
|
|
144
|
+
|
|
145
|
+
await completeTask(producer.id, "ready");
|
|
146
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
147
|
+
const woken = (await getScheduledTaskById(schedule.id))!;
|
|
148
|
+
// Woken before its ceiling: disabled with lastRunAt set, never left armed.
|
|
149
|
+
expect(woken.enabled).toBe(false);
|
|
150
|
+
expect(woken.nextRunAt).toBeUndefined();
|
|
151
|
+
expect(woken.lastRunAt).toBeDefined();
|
|
152
|
+
const wake = await wakeTask(schedule.id);
|
|
153
|
+
expect(wake?.task).toContain(`Wake-up cause: task.completed for task ${producer.id}`);
|
|
154
|
+
|
|
155
|
+
const { inside, past } = await horizons(schedule.id);
|
|
156
|
+
await reapSpentDeferredSchedules(inside);
|
|
157
|
+
expect(await getScheduledTaskById(schedule.id)).not.toBeNull();
|
|
158
|
+
|
|
159
|
+
await reapSpentDeferredSchedules(past);
|
|
160
|
+
expect(await getScheduledTaskById(schedule.id)).toBeNull();
|
|
161
|
+
expect(await waitRows(schedule.id)).toEqual({ waits: 0, members: 0 });
|
|
162
|
+
expect((await getTaskById(wake!.id))?.scheduleId).toBe(schedule.id);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("armed, never-fired, and ordinary one-time schedules are never reaped", async () => {
|
|
166
|
+
const farFuture = new Date(Date.now() + 10 * 365 * DAY_MS);
|
|
167
|
+
|
|
168
|
+
const armed = await defer((await activeTask()).id);
|
|
169
|
+
|
|
170
|
+
// Auto-disabled after a dispatch failure: no lastRunAt, kept as evidence.
|
|
171
|
+
const neverFired = await defer((await activeTask()).id);
|
|
172
|
+
await updateScheduledTask(neverFired.id, { enabled: false, lastErrorMessage: "boom" });
|
|
173
|
+
|
|
174
|
+
const ordinary = await createScheduledTask({
|
|
175
|
+
name: `ordinary-one-time-${crypto.randomUUID()}`,
|
|
176
|
+
taskTemplate: "one-off",
|
|
177
|
+
targetType: "agent-task",
|
|
178
|
+
scheduleType: "one_time",
|
|
179
|
+
nextRunAt: new Date(Date.now() + DAY_MS).toISOString(),
|
|
180
|
+
targetAgentId: agentId,
|
|
181
|
+
});
|
|
182
|
+
await updateScheduledTask(ordinary.id, {
|
|
183
|
+
enabled: false,
|
|
184
|
+
nextRunAt: null,
|
|
185
|
+
lastRunAt: new Date().toISOString(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await reapSpentDeferredSchedules(farFuture);
|
|
189
|
+
expect((await getScheduledTaskById(armed.id))?.enabled).toBe(true);
|
|
190
|
+
expect(await getScheduledTaskById(neverFired.id)).not.toBeNull();
|
|
191
|
+
expect(await getScheduledTaskById(ordinary.id)).not.toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -78,7 +78,7 @@ describe("docs API index footprint", () => {
|
|
|
78
78
|
expect(tasks).toContain('"path":"/api/task-templates","method":"get"');
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
test("OpenAPI and generated pages keep all
|
|
81
|
+
test("OpenAPI and generated pages keep all 383 operations", async () => {
|
|
82
82
|
const spec = await Bun.file(resolve(repoRoot, "openapi.json")).json();
|
|
83
83
|
const operationKeys = [];
|
|
84
84
|
const tags = new Set();
|
|
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
|
|
|
103
103
|
expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
|
|
104
104
|
|
|
105
105
|
const generatedKeys = await generatedOperationKeys();
|
|
106
|
-
expect(operationKeys).toHaveLength(
|
|
107
|
-
expect(generatedKeys).toHaveLength(
|
|
106
|
+
expect(operationKeys).toHaveLength(383);
|
|
107
|
+
expect(generatedKeys).toHaveLength(383);
|
|
108
108
|
expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import type { Subprocess } from "bun";
|
|
4
|
+
import { fetchProfileSyncRejectionBanner } from "../commands/profile-sync";
|
|
4
5
|
import { getFreePort, SERVER_BOOT_HOOK_TIMEOUT_MS } from "./test-net";
|
|
5
6
|
|
|
6
7
|
let TEST_PORT = 0;
|
|
@@ -270,6 +271,108 @@ describe("GET /api/events", () => {
|
|
|
270
271
|
});
|
|
271
272
|
});
|
|
272
273
|
|
|
274
|
+
describe("GET /api/events — latestPerDataField", () => {
|
|
275
|
+
const AGENT = "profile-sync-latest-agent";
|
|
276
|
+
const ids: Record<string, string> = {};
|
|
277
|
+
|
|
278
|
+
beforeAll(async () => {
|
|
279
|
+
const seed: [string, string, string][] = [
|
|
280
|
+
["tools-old", "system.profile_sync_rejected", "toolsMd"],
|
|
281
|
+
["claude-rejected", "system.profile_sync_rejected", "claudeMd"],
|
|
282
|
+
["soul-reconciled", "system.profile_sync_reconciled", "soulMd"],
|
|
283
|
+
["tools-new", "system.profile_sync_rejected", "toolsMd"],
|
|
284
|
+
["claude-reconciled", "system.profile_sync_reconciled", "claudeMd"],
|
|
285
|
+
["soul-rejected", "system.profile_sync_rejected", "soulMd"],
|
|
286
|
+
];
|
|
287
|
+
for (const [key, event, field] of seed) {
|
|
288
|
+
const { body } = await post("/api/events", {
|
|
289
|
+
category: "system",
|
|
290
|
+
event,
|
|
291
|
+
status: event.endsWith("rejected") ? "error" : "ok",
|
|
292
|
+
source: "api",
|
|
293
|
+
agentId: AGENT,
|
|
294
|
+
data: { field, diskSize: 30_000, dbSize: 19_000, budget: 20_000, delta: 11_000 },
|
|
295
|
+
});
|
|
296
|
+
ids[key] = (body.event as { id: string }).id;
|
|
297
|
+
// createdAt has millisecond precision: keep the seed strictly ordered.
|
|
298
|
+
await Bun.sleep(5);
|
|
299
|
+
}
|
|
300
|
+
await post("/api/events", {
|
|
301
|
+
category: "system",
|
|
302
|
+
event: "system.profile_sync_conflict",
|
|
303
|
+
source: "api",
|
|
304
|
+
agentId: AGENT,
|
|
305
|
+
data: { field: "toolsMd" },
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("returns the newest event per (event, data.field) and confirms the mode", async () => {
|
|
310
|
+
const { status, body } = await get(
|
|
311
|
+
`/api/events?events=system.profile_sync_rejected,system.profile_sync_reconciled&dataFields=soulMd,claudeMd,toolsMd,identityMd&agentId=${AGENT}&latestPerDataField=true`,
|
|
312
|
+
);
|
|
313
|
+
expect(status).toBe(200);
|
|
314
|
+
expect(body.latestPerDataField).toBe(true);
|
|
315
|
+
const returned = (body.events as { id: string }[]).map((event) => event.id);
|
|
316
|
+
expect(returned).toEqual([
|
|
317
|
+
ids["soul-rejected"],
|
|
318
|
+
ids["claude-reconciled"],
|
|
319
|
+
ids["tools-new"],
|
|
320
|
+
ids["soul-reconciled"],
|
|
321
|
+
ids["claude-rejected"],
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a single-event request keeps today's response shape", async () => {
|
|
326
|
+
const { status, body } = await get(
|
|
327
|
+
`/api/events?event=system.profile_sync_rejected&agentId=${AGENT}&dataField=toolsMd&limit=1`,
|
|
328
|
+
);
|
|
329
|
+
expect(status).toBe(200);
|
|
330
|
+
expect(Object.keys(body)).toEqual(["events"]);
|
|
331
|
+
expect((body.events as { id: string }[]).map((event) => event.id)).toEqual([ids["tools-new"]]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("rejects an unknown name in events", async () => {
|
|
335
|
+
const { status } = await get("/api/events?events=system.profile_sync_rejected,not.an.event");
|
|
336
|
+
expect(status).toBe(400);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("rejects latestPerDataField without dataFields", async () => {
|
|
340
|
+
const { status } = await get(
|
|
341
|
+
"/api/events?events=system.profile_sync_rejected&latestPerDataField=true",
|
|
342
|
+
);
|
|
343
|
+
expect(status).toBe(400);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("the profile-sync banner is identical through one batched call and 8 lookups", async () => {
|
|
347
|
+
const config = { apiUrl: BASE, apiKey: TEST_API_KEY, agentId: AGENT };
|
|
348
|
+
const eventRequests: string[] = [];
|
|
349
|
+
const recording = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
350
|
+
const url = new URL(String(input));
|
|
351
|
+
if (url.pathname === "/api/events") eventRequests.push(url.search);
|
|
352
|
+
return fetch(input, init);
|
|
353
|
+
}) as typeof fetch;
|
|
354
|
+
// Simulates an API that predates latestPerDataField: drop the confirmation.
|
|
355
|
+
const legacy = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
356
|
+
const response = await recording(input, init);
|
|
357
|
+
if (!new URL(String(input)).searchParams.has("latestPerDataField")) return response;
|
|
358
|
+
const { latestPerDataField: _dropped, ...rest } = await response.json();
|
|
359
|
+
return new Response(JSON.stringify(rest), { status: response.status });
|
|
360
|
+
}) as typeof fetch;
|
|
361
|
+
|
|
362
|
+
const batched = await fetchProfileSyncRejectionBanner(config, recording);
|
|
363
|
+
const batchedCalls = eventRequests.length;
|
|
364
|
+
eventRequests.length = 0;
|
|
365
|
+
const perField = await fetchProfileSyncRejectionBanner(config, legacy);
|
|
366
|
+
|
|
367
|
+
expect(batchedCalls).toBe(1);
|
|
368
|
+
expect(eventRequests).toHaveLength(9);
|
|
369
|
+
expect(batched).toContain(ids["tools-new"] ?? "missing");
|
|
370
|
+
expect(batched).toContain(ids["soul-rejected"] ?? "missing");
|
|
371
|
+
expect(batched).not.toContain(ids["claude-rejected"] ?? "missing");
|
|
372
|
+
expect(batched).toBe(perField);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
273
376
|
describe("GET /api/events/counts", () => {
|
|
274
377
|
test("returns counts grouped by event name", async () => {
|
|
275
378
|
const { status, body } = await get("/api/events/counts");
|
|
@@ -32,23 +32,28 @@ afterEach(async () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
describe("predefined extension catalog", () => {
|
|
35
|
-
test(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
test(
|
|
36
|
+
"every bundled entry passes the manifest schema and bundle validation",
|
|
37
|
+
async () => {
|
|
38
|
+
const catalog = getExtensionCatalog();
|
|
39
|
+
expect(Object.keys(catalog).sort()).toEqual([
|
|
40
|
+
"github-sender-allowlist",
|
|
41
|
+
"notify-on-complete",
|
|
42
|
+
"require-ticket-ref",
|
|
43
|
+
"require-verification-note",
|
|
44
|
+
"task-digest",
|
|
45
|
+
]);
|
|
46
|
+
for (const [name, entry] of Object.entries(catalog)) {
|
|
47
|
+
expect(entry.manifest.name).toBe(name);
|
|
48
|
+
expect(ExtensionManifestSchema.safeParse(entry.manifest).success).toBe(true);
|
|
49
|
+
const result = await validateBundle({ manifest: entry.manifest, files: entry.files });
|
|
50
|
+
expect(result).toMatchObject({ ok: true });
|
|
51
|
+
}
|
|
52
|
+
expect(getCatalogEntry("task-digest")?.manifestFile).toBe("manifest.yaml");
|
|
53
|
+
expect(getCatalogEntry("toString")).toBeNull();
|
|
54
|
+
},
|
|
55
|
+
{ timeout: 15_000 },
|
|
56
|
+
);
|
|
52
57
|
|
|
53
58
|
test("the generator reads YAML and JSON templates and their referenced files", async () => {
|
|
54
59
|
const dir = await templatesDir({
|