@desplega.ai/agent-swarm 2.2.0 → 2.4.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/README.md +1 -0
- package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-kedstxx6.js} +6 -6
- package/dist/{actions-w4pnb8vz.js → actions-vget6r1k.js} +18 -16
- package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-xeb5mktv.js} +2 -2
- package/dist/{app-zneem7g9.js → app-mqap8t8m.js} +3 -3
- package/dist/{assistant-wbzd2rnr.js → assistant-h0gv97mv.js} +20 -18
- package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-qr8a894z.js} +1 -1
- package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-rj85jjhw.js} +1 -1
- package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
- package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-xs9a8dx1.js} +4 -4
- package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-bm23cye7.js} +5 -5
- package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
- package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-gkfgks4j.js} +1 -1
- package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-apb4ypz3.js} +5 -5
- package/dist/cli-02vhhssk.js +848 -0
- package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
- package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
- package/dist/{cli-8c1m9mhy.js → cli-1cevz46c.js} +5 -5
- package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
- package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
- package/dist/{cli-vmbakbq0.js → cli-2rwet3r7.js} +1 -1
- package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
- package/dist/{cli-tba8fxf9.js → cli-4fhyq711.js} +45 -36
- package/dist/{cli-ak4c12mh.js → cli-5ff2sjyt.js} +1 -1
- package/dist/{cli-6q7pyd3k.js → cli-5s5hx57y.js} +5 -3
- package/dist/{cli-h23mr2s6.js → cli-6ftrrwm1.js} +9 -371
- package/dist/{cli-9ac0mkg5.js → cli-8b9wvjps.js} +1 -1
- package/dist/cli-8cxkw1f5.js +382 -0
- package/dist/{cli-xhgtay7w.js → cli-8rwvksbc.js} +2 -2
- package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
- package/dist/{cli-ksrphveb.js → cli-a1esmmee.js} +14 -2
- package/dist/{cli-xnp2vkx7.js → cli-avmqhatn.js} +7 -7
- package/dist/{cli-xx13gqf3.js → cli-b80a1100.js} +1 -1
- package/dist/{cli-z1hh99j0.js → cli-bfc3k1x5.js} +3 -3
- package/dist/{cli-0k9rcwfy.js → cli-bhwa8rzc.js} +33 -15
- package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
- package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
- package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
- package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
- package/dist/{cli-jrk8mj0t.js → cli-ecshf504.js} +2 -2
- package/dist/{cli-raejy0mp.js → cli-eqgfs202.js} +1 -1
- package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
- package/dist/{cli-c9vrwcpb.js → cli-ffbrkh1r.js} +74 -35
- package/dist/{cli-mnry16kh.js → cli-kk2r80n5.js} +4 -4
- package/dist/{cli-c0dpzwnx.js → cli-kz116kvw.js} +3 -3
- package/dist/{cli-e5y285rg.js → cli-mvg2hf8p.js} +4 -4
- package/dist/{cli-2jm992rm.js → cli-mz1ezzzh.js} +2 -2
- package/dist/{cli-dw0njd9q.js → cli-nme7wery.js} +6 -6
- package/dist/{cli-mvsfbbgq.js → cli-nrrg0fvy.js} +18 -4
- package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
- package/dist/{cli-4c7k75rq.js → cli-rg7ntypm.js} +378 -84
- package/dist/{cli-z3dk189k.js → cli-rsd7ga7v.js} +10 -8
- package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
- package/dist/{cli-8w65vwee.js → cli-tg9ksbn8.js} +1 -1
- package/dist/{cli-th8krqbf.js → cli-tgvpmnwz.js} +2 -2
- package/dist/{cli-hwc4a9xm.js → cli-tj4j2ts4.js} +6 -6
- package/dist/{cli-jhhb2v9n.js → cli-ttaz6c7m.js} +2 -2
- package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
- package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
- package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
- package/dist/{cli-5ks6x36q.js → cli-x1p5dhq2.js} +3 -3
- package/dist/{cli-bddxamg7.js → cli-x719j3vs.js} +220 -629
- package/dist/{cli-ztcxv5nv.js → cli-xfe63zx3.js} +1 -1
- package/dist/{cli-q6gw45wj.js → cli-ywyrffff.js} +1 -1
- package/dist/{cli-p46xdr1m.js → cli-zvze9rbm.js} +1 -1
- package/dist/cli.js +15 -15
- package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-pp2gq8gr.js} +3 -3
- package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
- package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-0vzn09t8.js} +3 -3
- package/dist/{commands-ktnqwrv4.js → commands-psqhkttb.js} +2 -2
- package/dist/{controller-nex2he8v.js → controller-t20vz2k8.js} +2 -2
- package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-gkxmqw9d.js} +24 -20
- package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-3v542qe5.js} +1 -1
- package/dist/{db-jvr86v7f.js → db-2xbyenjr.js} +2 -2
- package/dist/{db-19tw9r5s.js → db-nc1rd869.js} +3 -2
- package/dist/{dispatcher-d3mnsg2y.js → dispatcher-ewceb8ts.js} +17 -15
- package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-75xwh78t.js} +1 -1
- package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
- package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
- package/dist/{external-channel-44cwer32.js → external-channel-a5dsmxn4.js} +2 -2
- package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
- package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
- package/dist/{grok-adapter-ysp907f6.js → grok-adapter-vj88xr23.js} +7 -7
- package/dist/handlers-zbs90bny.js +39 -0
- package/dist/{heartbeat-2etacsqq.js → heartbeat-t2m9r3wa.js} +20 -18
- package/dist/{hook-mzxxs5n2.js → hook-8femx1bg.js} +4 -4
- package/dist/{hook-3kvmax6d.js → hook-xa677f8r.js} +5 -5
- package/dist/{http-fn978m0g.js → http-xpmmq028.js} +940 -269
- package/dist/{index-e4tsa1h7.js → index-8pwc1jc8.js} +17 -15
- package/dist/{index-w2wr89xd.js → index-cdzyfrsp.js} +1 -1
- package/dist/{index-1r8hw4vd.js → index-dg2tmj99.js} +17 -15
- package/dist/index-nxa6me0q.js +41 -0
- package/dist/{index-039x2sag.js → index-s7ckdajq.js} +42 -14
- package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
- package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
- package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
- package/dist/{keepalive-d4nzs76x.js → keepalive-3nr4st3r.js} +3 -3
- package/dist/{lead-8h5n9k5f.js → lead-td8h2sfn.js} +33 -31
- package/dist/{maintenance-ea6jfw42.js → maintenance-verwpe2c.js} +1 -1
- package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
- package/dist/{onboard-pvk7hdb2.js → onboard-aytqedmq.js} +2 -2
- package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
- package/dist/{otel-impl-qb7nhgdp.js → otel-impl-hckw4wt1.js} +1 -1
- package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
- package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-5nvme3sm.js} +3 -3
- package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-pd8c26m1.js} +5 -3
- package/dist/{rbac-roles-9648nh00.js → rbac-roles-vbpy21mg.js} +2 -2
- package/dist/{render-v2-xkze1wef.js → render-v2-7nqgvk2q.js} +3 -3
- package/dist/{resume-tq0bbvt5.js → resume-b6djcj3c.js} +6 -6
- package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
- package/dist/session-access-fc3f9y12.js +25 -0
- package/dist/{setup-rnrazxvd.js → setup-n39szm6b.js} +1 -1
- package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
- package/dist/{work-objects-e75q2rqs.js → work-objects-jem2fre0.js} +20 -18
- package/dist/{worker-4ww4hjef.js → worker-wr6xgfr2.js} +33 -31
- package/openapi.json +1011 -115
- package/package.json +2 -2
- package/src/apps/action-caller.ts +6 -0
- package/src/apps/script-types.ts +16 -3
- package/src/be/asset-key-audit.ts +4 -13
- package/src/be/db/active-sessions.ts +5 -0
- package/src/be/db/session-visibility.ts +29 -0
- package/src/be/db/tasks/read.ts +61 -3
- package/src/be/db/tasks/write.ts +15 -6
- package/src/be/db.ts +149 -66
- package/src/be/events.ts +11 -0
- package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
- package/src/be/migrations/230_session_shares.sql +23 -0
- package/src/be/rbac-roles.ts +2 -0
- package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
- package/src/be/session-shares.ts +244 -0
- package/src/be/swarm-config-guard.ts +23 -2
- package/src/commands/runner.ts +11 -0
- package/src/heartbeat/heartbeat.ts +25 -7
- package/src/http/active-sessions.ts +13 -2
- package/src/http/agents.ts +10 -3
- package/src/http/all-routes.ts +1 -0
- package/src/http/api-keys.ts +3 -1
- package/src/http/approval-requests.ts +2 -1
- package/src/http/apps.ts +36 -11
- package/src/http/assets.ts +26 -4
- package/src/http/config.ts +3 -0
- package/src/http/context.ts +2 -0
- package/src/http/core.ts +39 -2
- package/src/http/events.ts +6 -2
- package/src/http/favorite-owner.ts +1 -1
- package/src/http/fs.ts +38 -3
- package/src/http/inbox-state.ts +58 -8
- package/src/http/index.ts +3 -1
- package/src/http/memory.ts +3 -1
- package/src/http/ranking.ts +367 -0
- package/src/http/realtime.ts +26 -21
- package/src/http/route-def.ts +77 -11
- package/src/http/script-runs.ts +2 -1
- package/src/http/scripts.ts +8 -0
- package/src/http/session-access.ts +179 -0
- package/src/http/session-data.ts +10 -2
- package/src/http/sessions.ts +212 -12
- package/src/http/stats.ts +7 -3
- package/src/http/tasks.ts +83 -7
- package/src/http/used-by.ts +11 -3
- package/src/kv-reserved-namespaces.ts +1 -0
- package/src/prompts/session-templates.ts +3 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/ranking/telemetry-ranking.ts +147 -0
- package/src/ranking/usage.ts +57 -0
- package/src/rbac/admission.ts +22 -7
- package/src/rbac/legacy-policy.ts +2 -0
- package/src/rbac/permissions.ts +9 -0
- package/src/realtime/auth.ts +69 -0
- package/src/realtime/tickets.ts +4 -1
- package/src/realtime/transport.ts +77 -9
- package/src/slack/blocks.ts +18 -0
- package/src/slack/dm.ts +29 -0
- package/src/telemetry-contract/EVENTS.md +3 -0
- package/src/telemetry-contract/SOURCE.json +37 -4
- package/src/telemetry-contract/catalog.json +69 -0
- package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
- package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
- package/src/telemetry-contract/ranking/taxonomy.json +8 -0
- package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
- package/src/telemetry-contract/types.gen.ts +708 -1
- package/src/telemetry.ts +26 -2
- package/src/tests/apps-rbac.test.ts +189 -2
- package/src/tests/apps-script-types.test.ts +16 -3
- package/src/tests/asset-key-api.test.ts +128 -0
- package/src/tests/asset-key-invariants.test.ts +179 -3
- package/src/tests/asset-key-migration.test.ts +35 -0
- package/src/tests/check-pr-body.test.ts +23 -0
- package/src/tests/check-ranking-entry.test.ts +146 -0
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/heartbeat-checklist.test.ts +88 -0
- package/src/tests/heartbeat-jev-gate.test.ts +394 -10
- package/src/tests/inbox-state.test.ts +167 -0
- package/src/tests/kv-tool.test.ts +15 -0
- package/src/tests/org-identity.test.ts +44 -0
- package/src/tests/ranking-listing.test.ts +112 -0
- package/src/tests/ranking-route.test.ts +319 -0
- package/src/tests/ranking-usage.test.ts +50 -0
- package/src/tests/rbac-admission-e2e.test.ts +8 -0
- package/src/tests/rbac-admission.test.ts +38 -0
- package/src/tests/rbac-charact-http.test.ts +4 -3
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +2 -0
- package/src/tests/realtime-room-surfaces.test.ts +3 -1
- package/src/tests/realtime-session-join-race.test.ts +142 -0
- package/src/tests/realtime-tickets.test.ts +24 -17
- package/src/tests/realtime-transport.test.ts +91 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/session-route-posture.test.ts +639 -0
- package/src/tests/session-sharing-routes.test.ts +282 -0
- package/src/tests/session-sharing.test.ts +526 -0
- package/src/tests/sessions-detail-batch.test.ts +7 -1
- package/src/tests/slack-tool-blocks.test.ts +101 -0
- package/src/tests/slack-update.test.ts +55 -0
- package/src/tests/slack-user-dm.test.ts +177 -0
- package/src/tests/used-by-route.test.ts +10 -0
- package/src/tools/slack-post.ts +18 -12
- package/src/tools/slack-read.ts +13 -1
- package/src/tools/slack-reply.ts +7 -10
- package/src/tools/slack-start-thread.ts +18 -11
- package/src/tools/slack-update.ts +2 -9
- package/src/types.ts +37 -0
- package/src/utils/org-identity.ts +33 -0
- package/templates/skills/apps/SKILL.md +17 -0
- package/templates/skills/apps/content.md +17 -0
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/pages/content.md +4 -0
- package/templates/skills/swarm-scripts/SKILL.md +6 -0
- package/templates/skills/swarm-scripts/content.md +6 -0
- package/dist/handlers-t5pbabwf.js +0 -37
- package/dist/index-qyjbb7br.js +0 -39
- package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
- /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
1
2
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import {
|
|
@@ -19,7 +20,9 @@ type Fixture = {
|
|
|
19
20
|
configs: Record<string, string>;
|
|
20
21
|
heartbeatMd?: string;
|
|
21
22
|
prevStatus?: string;
|
|
23
|
+
reportedFailures?: unknown;
|
|
22
24
|
prevOutput?: string | null;
|
|
25
|
+
prevCreatedAt?: string | null;
|
|
23
26
|
failed?: Array<Record<string, unknown>>;
|
|
24
27
|
approvalStatus?: string;
|
|
25
28
|
/** Stands in for `ctx.swarm.systemOne_decide`; returns the bridge result. */
|
|
@@ -33,6 +36,7 @@ function fakeCtx(f: Fixture) {
|
|
|
33
36
|
kvSets: 0,
|
|
34
37
|
decides: [] as Array<Record<string, any>>,
|
|
35
38
|
approvalReads: 0,
|
|
39
|
+
previousReads: 0,
|
|
36
40
|
};
|
|
37
41
|
const table = (columns: string[], rows: unknown[][]) => ({
|
|
38
42
|
success: true,
|
|
@@ -56,17 +60,48 @@ function fakeCtx(f: Fixture) {
|
|
|
56
60
|
},
|
|
57
61
|
};
|
|
58
62
|
},
|
|
59
|
-
db_query: async ({ sql }: { sql: string }) => {
|
|
63
|
+
db_query: async ({ sql, params = [] }: { sql: string; params?: any[] }) => {
|
|
60
64
|
if (sql.includes("FROM approval_requests")) {
|
|
61
65
|
calls.approvalReads++;
|
|
62
66
|
return table(["status"], f.approvalStatus ? [[f.approvalStatus]] : []);
|
|
63
67
|
}
|
|
64
68
|
if (sql.includes("heartbeatMd")) return table(["heartbeatMd"], [[f.heartbeatMd ?? ""]]);
|
|
65
|
-
if (sql.includes("'heartbeat-checklist'")) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
if (sql.includes("'heartbeat-checklist'") || sql.includes("finishedAt <= ?")) {
|
|
70
|
+
if (sql.includes("'heartbeat-checklist'")) calls.previousReads++;
|
|
71
|
+
// Execute historical selection against real rows, including its bounds and limit.
|
|
72
|
+
const db = new Database(":memory:");
|
|
73
|
+
try {
|
|
74
|
+
db.run(`CREATE TABLE agent_tasks (
|
|
75
|
+
id TEXT, output TEXT, task TEXT, agentId TEXT, taskType TEXT,
|
|
76
|
+
status TEXT, createdAt TEXT, finishedAt TEXT
|
|
77
|
+
)`);
|
|
78
|
+
if (f.prevCreatedAt !== null)
|
|
79
|
+
db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
80
|
+
"prev-id",
|
|
81
|
+
f.prevOutput ?? null,
|
|
82
|
+
f.prevStatus ?? "",
|
|
83
|
+
LEAD,
|
|
84
|
+
"heartbeat-checklist",
|
|
85
|
+
"completed",
|
|
86
|
+
f.prevCreatedAt ?? "2026-10-15T13:00:00.000Z",
|
|
87
|
+
null,
|
|
88
|
+
]);
|
|
89
|
+
for (const failure of f.failed ?? []) {
|
|
90
|
+
db.run("INSERT INTO agent_tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
91
|
+
failure.id,
|
|
92
|
+
null,
|
|
93
|
+
failure.task,
|
|
94
|
+
failure.agentId,
|
|
95
|
+
"test",
|
|
96
|
+
"failed",
|
|
97
|
+
"2026-10-15T06:00:00.000Z",
|
|
98
|
+
failure.finishedAt,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
return { data: { rows: db.query(sql).all(...params) } };
|
|
102
|
+
} finally {
|
|
103
|
+
db.close();
|
|
104
|
+
}
|
|
70
105
|
}
|
|
71
106
|
if (sql.includes("completed24")) {
|
|
72
107
|
return table(
|
|
@@ -75,7 +110,10 @@ function fakeCtx(f: Fixture) {
|
|
|
75
110
|
);
|
|
76
111
|
}
|
|
77
112
|
if (sql.includes("status = 'failed' AND finishedAt")) {
|
|
78
|
-
const rows = f.failed ?? []
|
|
113
|
+
const rows = (f.failed ?? [])
|
|
114
|
+
.filter((r) => String(r.finishedAt) > params[0])
|
|
115
|
+
.sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)))
|
|
116
|
+
.slice(0, 10);
|
|
79
117
|
return table(
|
|
80
118
|
["id", "agentId", "task", "failureReason", "finishedAt"],
|
|
81
119
|
rows.map((r) => [r.id, r.agentId, r.task, r.failureReason, r.finishedAt]),
|
|
@@ -93,8 +131,13 @@ function fakeCtx(f: Fixture) {
|
|
|
93
131
|
}
|
|
94
132
|
throw new Error(`unexpected sql: ${sql}`);
|
|
95
133
|
},
|
|
96
|
-
kv_getOrNull: async ({ key }: { key: string }) =>
|
|
97
|
-
|
|
134
|
+
kv_getOrNull: async ({ namespace, key }: { namespace: string; key: string }) => {
|
|
135
|
+
if (namespace === "heartbeat-checklist:reported-failures") {
|
|
136
|
+
expect(key).toBe("prev-id");
|
|
137
|
+
return f.reportedFailures === undefined ? null : { key, value: f.reportedFailures };
|
|
138
|
+
}
|
|
139
|
+
return kv.has(key) ? { key, value: kv.get(key) } : null;
|
|
140
|
+
},
|
|
98
141
|
kv_set: async ({ key, value }: { key: string; value: unknown }) => {
|
|
99
142
|
calls.kvSets++;
|
|
100
143
|
kv.set(key, value);
|
|
@@ -168,6 +211,7 @@ describe("heartbeat-jev-gate with a provider", () => {
|
|
|
168
211
|
test("wake at or above the threshold, with the new failures and due items in summary", async () => {
|
|
169
212
|
const { ctx } = fakeCtx({
|
|
170
213
|
configs: { TYPESAFE_API_KEY: "x" },
|
|
214
|
+
reportedFailures: ["aaaaaaaa-0000"],
|
|
171
215
|
heartbeatMd: [
|
|
172
216
|
"# Standing orders",
|
|
173
217
|
`- Watch: release on ${MMDD} — lift after ship`,
|
|
@@ -176,7 +220,7 @@ describe("heartbeat-jev-gate with a provider", () => {
|
|
|
176
220
|
].join("\n"),
|
|
177
221
|
// The previous checklist already listed aaaaaaaa, so only bbbbbbbb is new.
|
|
178
222
|
prevStatus:
|
|
179
|
-
'## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
|
|
223
|
+
'## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
|
|
180
224
|
prevOutput: "All clear.\n\nNothing to do.",
|
|
181
225
|
failed: [
|
|
182
226
|
{
|
|
@@ -217,6 +261,346 @@ describe("heartbeat-jev-gate with a provider", () => {
|
|
|
217
261
|
});
|
|
218
262
|
});
|
|
219
263
|
|
|
264
|
+
for (const ordersFirst of [true, false]) {
|
|
265
|
+
test(`previous failures are not new with standing orders ${ordersFirst ? "before" : "after"} status`, async () => {
|
|
266
|
+
const failures =
|
|
267
|
+
'## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n';
|
|
268
|
+
const orders = "## Your Standing Orders\n- Already triaged.\n";
|
|
269
|
+
const status = `## Current System Status [auto-generated]\n${failures}`;
|
|
270
|
+
const { ctx } = fakeCtx({
|
|
271
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
272
|
+
reportedFailures: ["aaaaaaaa-0000"],
|
|
273
|
+
prevStatus: ordersFirst ? orders + status : status + orders,
|
|
274
|
+
failed: [
|
|
275
|
+
{
|
|
276
|
+
id: "aaaaaaaa-0000",
|
|
277
|
+
agentId: "12345678-bbbb",
|
|
278
|
+
task: "old failure",
|
|
279
|
+
failureReason: "boom",
|
|
280
|
+
finishedAt: "2026-10-15T12:00:00Z",
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "bbbbbbbb-0000",
|
|
284
|
+
agentId: "12345678-bbbb",
|
|
285
|
+
task: "new failure",
|
|
286
|
+
failureReason: "exit 1",
|
|
287
|
+
finishedAt: "2026-10-15T13:01:00.000Z",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
jev: jevSays(0.9),
|
|
291
|
+
});
|
|
292
|
+
const verdict = await run(ctx, { dryRun: true });
|
|
293
|
+
expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
|
|
294
|
+
"bbbbbbbb: new failure | exit 1",
|
|
295
|
+
]);
|
|
296
|
+
expect(verdict.state.failures_last_6h_total).toBe(2);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
test.each([
|
|
301
|
+
"## Recent Failures (last 6h) [auto-generated]\nNone.\n",
|
|
302
|
+
"",
|
|
303
|
+
])("failures outside the previous Recent Failures section stay new (%s)", async (section) => {
|
|
304
|
+
const { ctx } = fakeCtx({
|
|
305
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
306
|
+
prevStatus: `## Current System Status [auto-generated]\n${section}## Gate Findings [auto-generated]\n- [aaaaaaaa] "quoted failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n`,
|
|
307
|
+
failed: [
|
|
308
|
+
{
|
|
309
|
+
id: "aaaaaaaa-0000",
|
|
310
|
+
agentId: "12345678-bbbb",
|
|
311
|
+
task: "quoted failure",
|
|
312
|
+
failureReason: "boom",
|
|
313
|
+
finishedAt: "2026-10-15T13:01:00.000Z",
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
jev: jevSays(0.9),
|
|
317
|
+
});
|
|
318
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
319
|
+
"aaaaaaaa: quoted failure | boom",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test.each([
|
|
324
|
+
"- Item 544: fix `## Recent Failures` parsing.\n",
|
|
325
|
+
"## Recent Failures\nSnapshot notes.\n",
|
|
326
|
+
'## Recent Failures (last 6h) [auto-generated]\n- [bbbbbbbb] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z\n',
|
|
327
|
+
])("snapshot failure headings cannot hijack generated status (%s)", async (snapshot) => {
|
|
328
|
+
const { ctx, calls } = fakeCtx({
|
|
329
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
330
|
+
reportedFailures: ["aaaaaaaa-0000"],
|
|
331
|
+
prevStatus: [
|
|
332
|
+
"## Your Standing Orders (snapshot from HEARTBEAT.md)",
|
|
333
|
+
snapshot,
|
|
334
|
+
"## Instructions",
|
|
335
|
+
"Review the generated status.",
|
|
336
|
+
"## Current System Status [auto-generated]",
|
|
337
|
+
"## Recent Failures (last 6h) [auto-generated]",
|
|
338
|
+
'- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
|
|
339
|
+
].join("\n"),
|
|
340
|
+
failed: [
|
|
341
|
+
{
|
|
342
|
+
id: "aaaaaaaa-0000",
|
|
343
|
+
agentId: "12345678-bbbb",
|
|
344
|
+
task: "old failure",
|
|
345
|
+
failureReason: "boom",
|
|
346
|
+
finishedAt: "2026-10-15T12:00:00Z",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "bbbbbbbb-0000",
|
|
350
|
+
agentId: "12345678-bbbb",
|
|
351
|
+
task: "snapshot quote",
|
|
352
|
+
failureReason: "boom",
|
|
353
|
+
finishedAt: "2026-10-15T13:01:00.000Z",
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
jev: jevSays(0.9),
|
|
357
|
+
});
|
|
358
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
359
|
+
"bbbbbbbb: snapshot quote | boom",
|
|
360
|
+
]);
|
|
361
|
+
expect(calls.previousReads).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("an absent generated failures section cannot use a trailing snapshot heading", async () => {
|
|
365
|
+
const { ctx, calls } = fakeCtx({
|
|
366
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
367
|
+
prevStatus: [
|
|
368
|
+
"## Current System Status [auto-generated]",
|
|
369
|
+
"## Task Overview [auto-generated]",
|
|
370
|
+
"- Failed (24h): 0",
|
|
371
|
+
"## Agent Status [auto-generated]",
|
|
372
|
+
"- Online: 6",
|
|
373
|
+
"## Your Standing Orders (snapshot from HEARTBEAT.md)",
|
|
374
|
+
"## Recent Failures (last 6h) [auto-generated]",
|
|
375
|
+
'- [aaaaaaaa] "snapshot quote" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
|
|
376
|
+
].join("\n"),
|
|
377
|
+
failed: [
|
|
378
|
+
{
|
|
379
|
+
id: "aaaaaaaa-0000",
|
|
380
|
+
agentId: "12345678-bbbb",
|
|
381
|
+
task: "snapshot quote",
|
|
382
|
+
failureReason: "boom",
|
|
383
|
+
finishedAt: "2026-10-15T13:01:00.000Z",
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
jev: jevSays(0.9),
|
|
387
|
+
});
|
|
388
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
389
|
+
"aaaaaaaa: snapshot quote | boom",
|
|
390
|
+
]);
|
|
391
|
+
expect(calls.previousReads).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("a failed-task title heading does not hide later failures", async () => {
|
|
395
|
+
const { ctx, calls } = fakeCtx({
|
|
396
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
397
|
+
reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
|
|
398
|
+
prevStatus: [
|
|
399
|
+
"## Current System Status [auto-generated]",
|
|
400
|
+
"## Recent Failures (last 6h) [auto-generated]",
|
|
401
|
+
'- [aaaaaaaa] "Fix the task\n## Requirements" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
|
|
402
|
+
'- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
|
|
403
|
+
"## Gate Findings [auto-generated]",
|
|
404
|
+
'- [cccccccc] "outside failures" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00Z',
|
|
405
|
+
].join("\n"),
|
|
406
|
+
failed: [
|
|
407
|
+
{
|
|
408
|
+
id: "aaaaaaaa-0000",
|
|
409
|
+
agentId: "12345678-bbbb",
|
|
410
|
+
task: "Fix the task\n## Requirements",
|
|
411
|
+
failureReason: "boom",
|
|
412
|
+
finishedAt: "2026-10-15T12:00:00Z",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: "bbbbbbbb-0000",
|
|
416
|
+
agentId: "12345678-bbbb",
|
|
417
|
+
task: "later failure",
|
|
418
|
+
failureReason: "boom",
|
|
419
|
+
finishedAt: "2026-10-15T12:00:00Z",
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
id: "cccccccc-0000",
|
|
423
|
+
agentId: "12345678-bbbb",
|
|
424
|
+
task: "outside failures",
|
|
425
|
+
failureReason: "boom",
|
|
426
|
+
finishedAt: "2026-10-15T13:01:00.000Z",
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
jev: jevSays(0.9),
|
|
430
|
+
});
|
|
431
|
+
const verdict = await run(ctx, { dryRun: true });
|
|
432
|
+
expect(verdict.state.new_failures_since_previous_heartbeat).toEqual([
|
|
433
|
+
"cccccccc: outside failures | boom",
|
|
434
|
+
]);
|
|
435
|
+
expect(verdict.state.failures_last_6h_total).toBe(3);
|
|
436
|
+
expect(calls.previousReads).toBe(1);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("code-fenced snapshot system and agent markers cannot hide previous failures", async () => {
|
|
440
|
+
const { ctx, calls } = fakeCtx({
|
|
441
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
442
|
+
reportedFailures: ["aaaaaaaa-0000"],
|
|
443
|
+
prevStatus: [
|
|
444
|
+
"## Your Standing Orders (snapshot from HEARTBEAT.md)",
|
|
445
|
+
"```md",
|
|
446
|
+
"## Current System Status [auto-generated]",
|
|
447
|
+
"## Agent Status [auto-generated]",
|
|
448
|
+
"```",
|
|
449
|
+
"## Current System Status [auto-generated]",
|
|
450
|
+
"## Recent Failures (last 6h) [auto-generated]",
|
|
451
|
+
'- [aaaaaaaa] "old failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
|
|
452
|
+
"## Agent Status [auto-generated]",
|
|
453
|
+
].join("\n"),
|
|
454
|
+
failed: [
|
|
455
|
+
{
|
|
456
|
+
id: "aaaaaaaa-0000",
|
|
457
|
+
agentId: "12345678-bbbb",
|
|
458
|
+
task: "old failure",
|
|
459
|
+
failureReason: "boom",
|
|
460
|
+
finishedAt: "2026-10-15T12:00:00.000Z",
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
jev: jevSays(0.9),
|
|
464
|
+
});
|
|
465
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
|
|
466
|
+
"none",
|
|
467
|
+
);
|
|
468
|
+
expect(calls.previousReads).toBe(1);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("a generated Requirements heading in a failed title cannot hide later failures", async () => {
|
|
472
|
+
const { ctx } = fakeCtx({
|
|
473
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
474
|
+
reportedFailures: ["aaaaaaaa-0000", "bbbbbbbb-0000"],
|
|
475
|
+
prevStatus: [
|
|
476
|
+
"## Current System Status [auto-generated]",
|
|
477
|
+
"## Recent Failures (last 6h) [auto-generated]",
|
|
478
|
+
'- [aaaaaaaa] "Fix\n## Requirements [auto-generated]\nbody" — agent: 12345678, reason: boom, at: 2026-10-15T12:00:00.000Z',
|
|
479
|
+
'- [bbbbbbbb] "later failure" — agent: 12345678, reason: boom, at: 2026-10-15T12:01:00.000Z',
|
|
480
|
+
"## Agent Status [auto-generated]",
|
|
481
|
+
].join("\n"),
|
|
482
|
+
failed: [
|
|
483
|
+
{
|
|
484
|
+
id: "aaaaaaaa-0000",
|
|
485
|
+
agentId: "12345678-bbbb",
|
|
486
|
+
task: "Fix\n## Requirements [auto-generated]\nbody",
|
|
487
|
+
failureReason: "boom",
|
|
488
|
+
finishedAt: "2026-10-15T12:00:00.000Z",
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: "bbbbbbbb-0000",
|
|
492
|
+
agentId: "12345678-bbbb",
|
|
493
|
+
task: "later failure",
|
|
494
|
+
failureReason: "boom",
|
|
495
|
+
finishedAt: "2026-10-15T12:01:00.000Z",
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
jev: jevSays(0.9),
|
|
499
|
+
});
|
|
500
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toBe(
|
|
501
|
+
"none",
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("a failure finished after previous creation stays new even if its ID is quoted", async () => {
|
|
506
|
+
const { ctx } = fakeCtx({
|
|
507
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
508
|
+
prevStatus:
|
|
509
|
+
'## Current System Status [auto-generated]\n## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "new failure" — agent: 12345678, reason: boom, at: 2026-10-15T13:00:00.001Z',
|
|
510
|
+
failed: [
|
|
511
|
+
{
|
|
512
|
+
id: "aaaaaaaa-0000",
|
|
513
|
+
agentId: "12345678-bbbb",
|
|
514
|
+
task: "new failure",
|
|
515
|
+
failureReason: "boom",
|
|
516
|
+
finishedAt: "2026-10-15T13:00:00.001Z",
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
jev: jevSays(0.9),
|
|
520
|
+
});
|
|
521
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
522
|
+
"aaaaaaaa: new failure | boom",
|
|
523
|
+
]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test.each([
|
|
527
|
+
{ reportedFailures: ["aaaaaaaa-0000"] },
|
|
528
|
+
{ reportedFailures: '["aaaaaaaa-0000"]' },
|
|
529
|
+
])("a failure unseen during gathering stays new before checklist creation (%j)", async ({
|
|
530
|
+
reportedFailures,
|
|
531
|
+
}) => {
|
|
532
|
+
const { ctx } = fakeCtx({
|
|
533
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
534
|
+
reportedFailures,
|
|
535
|
+
prevCreatedAt: "2026-10-15T13:00:00.000Z",
|
|
536
|
+
failed: [
|
|
537
|
+
{
|
|
538
|
+
id: "aaaaaaaa-0000",
|
|
539
|
+
agentId: "12345678-bbbb",
|
|
540
|
+
task: "reported",
|
|
541
|
+
failureReason: "boom",
|
|
542
|
+
finishedAt: "2026-10-15T12:00:00.000Z",
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: "bbbbbbbb-0000",
|
|
546
|
+
agentId: "12345678-bbbb",
|
|
547
|
+
task: "unseen",
|
|
548
|
+
failureReason: "boom",
|
|
549
|
+
finishedAt: "2026-10-15T12:59:59.999Z",
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
jev: jevSays(0.9),
|
|
553
|
+
});
|
|
554
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
555
|
+
"bbbbbbbb: unseen | boom",
|
|
556
|
+
]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test.each(
|
|
560
|
+
[undefined, "not JSON", {}, [42]].map((reportedFailures) => ({ reportedFailures })),
|
|
561
|
+
)("missing or invalid receipts treat every current failure as new (%j)", async ({
|
|
562
|
+
reportedFailures,
|
|
563
|
+
}) => {
|
|
564
|
+
const { ctx } = fakeCtx({
|
|
565
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
566
|
+
reportedFailures,
|
|
567
|
+
prevStatus: '## Recent Failures (last 6h) [auto-generated]\n- [aaaaaaaa] "quoted"',
|
|
568
|
+
failed: [
|
|
569
|
+
{
|
|
570
|
+
id: "aaaaaaaa-0000",
|
|
571
|
+
agentId: "12345678-bbbb",
|
|
572
|
+
task: "still new",
|
|
573
|
+
failureReason: "boom",
|
|
574
|
+
finishedAt: "2026-10-15T12:00:00.000Z",
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
jev: jevSays(0.9),
|
|
578
|
+
});
|
|
579
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
580
|
+
"aaaaaaaa: still new | boom",
|
|
581
|
+
]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("without a previous checklist all current failures are new", async () => {
|
|
585
|
+
const { ctx } = fakeCtx({
|
|
586
|
+
configs: { TYPESAFE_API_KEY: "x" },
|
|
587
|
+
prevCreatedAt: null,
|
|
588
|
+
failed: [
|
|
589
|
+
{
|
|
590
|
+
id: "aaaaaaaa-0000",
|
|
591
|
+
agentId: "12345678-bbbb",
|
|
592
|
+
task: "first failure",
|
|
593
|
+
failureReason: "boom",
|
|
594
|
+
finishedAt: "2026-10-15T12:00:00.000Z",
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
jev: jevSays(0.9),
|
|
598
|
+
});
|
|
599
|
+
expect((await run(ctx, { dryRun: true })).state.new_failures_since_previous_heartbeat).toEqual([
|
|
600
|
+
"aaaaaaaa: first failure | boom",
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
|
|
220
604
|
test("threshold and time zone come from swarm config", async () => {
|
|
221
605
|
const { ctx } = fakeCtx({
|
|
222
606
|
configs: {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createUser,
|
|
6
|
+
getDbClient,
|
|
7
|
+
initDb,
|
|
8
|
+
listInboxState,
|
|
9
|
+
upsertInboxState,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import { handleInboxState } from "../http/inbox-state";
|
|
12
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import { listenOnFreePort } from "./test-net";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-inbox-state.sqlite";
|
|
16
|
+
const earlier = "2026-01-01T00:00:00.000Z";
|
|
17
|
+
const later = "2026-02-01T00:00:00.000Z";
|
|
18
|
+
let userId: string;
|
|
19
|
+
let server: Server;
|
|
20
|
+
let baseUrl: string;
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
initDb(TEST_DB_PATH);
|
|
24
|
+
userId = (await createUser({ name: "Inbox User", email: "inbox-state@example.com" })).id;
|
|
25
|
+
server = createServer(async (req, res) => {
|
|
26
|
+
const url = req.url ?? "";
|
|
27
|
+
if (await handleInboxState(req, res, getPathSegments(url), parseQueryParams(url))) return;
|
|
28
|
+
res.writeHead(404).end();
|
|
29
|
+
});
|
|
30
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
35
|
+
closeDb();
|
|
36
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
37
|
+
const file = Bun.file(`${TEST_DB_PATH}${suffix}`);
|
|
38
|
+
if (await file.exists()) await file.delete();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function patch(path: string, body: Record<string, unknown>) {
|
|
43
|
+
return fetch(`${baseUrl}/api/inbox-state${path}`, {
|
|
44
|
+
method: "PATCH",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ userId, ...body }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("inbox read cursor", () => {
|
|
51
|
+
test("sets, preserves, moves back, and clears the cursor independently of sticky readAt", async () => {
|
|
52
|
+
const item = {
|
|
53
|
+
userId,
|
|
54
|
+
itemType: "to_read" as const,
|
|
55
|
+
itemId: "cursor",
|
|
56
|
+
status: "open" as const,
|
|
57
|
+
};
|
|
58
|
+
const initial = await upsertInboxState(item);
|
|
59
|
+
expect(initial.lastReadAt).toBeUndefined();
|
|
60
|
+
const set = await upsertInboxState({ ...item, readAt: earlier, lastReadAt: later });
|
|
61
|
+
expect(set.lastReadAt).toBe(later);
|
|
62
|
+
expect((await upsertInboxState({ ...item, readAt: later })).lastReadAt).toBe(later);
|
|
63
|
+
const movedBack = await upsertInboxState({ ...item, readAt: later, lastReadAt: earlier });
|
|
64
|
+
expect(movedBack.lastReadAt).toBe(earlier);
|
|
65
|
+
expect(movedBack.readAt).toBe(earlier);
|
|
66
|
+
const cleared = await upsertInboxState({ ...item, status: "done", lastReadAt: null });
|
|
67
|
+
expect(cleared.lastReadAt).toBeUndefined();
|
|
68
|
+
expect(cleared.readAt).toBe(earlier);
|
|
69
|
+
expect(cleared.status).toBe("done");
|
|
70
|
+
expect(
|
|
71
|
+
(await listInboxState({ userId })).find((row) => row.id === set.id)?.lastReadAt,
|
|
72
|
+
).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("single PATCH accepts a timestamp and null", async () => {
|
|
76
|
+
const item = { itemType: "to_read", itemId: "http-cursor", status: "open" };
|
|
77
|
+
const set = await patch("", { ...item, readAt: earlier, lastReadAt: later });
|
|
78
|
+
expect(set.status).toBe(200);
|
|
79
|
+
expect((await set.json()).item.lastReadAt).toBe(later);
|
|
80
|
+
const cleared = await patch("", { ...item, lastReadAt: null });
|
|
81
|
+
expect(cleared.status).toBe(200);
|
|
82
|
+
expect((await cleared.json()).item).toMatchObject({ readAt: earlier });
|
|
83
|
+
expect(
|
|
84
|
+
(await listInboxState({ userId })).find((row) => row.itemId === item.itemId)?.lastReadAt,
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("bulk inbox state", () => {
|
|
90
|
+
test("returns rows in request order and applies single-item cursor semantics", async () => {
|
|
91
|
+
const item = { itemType: "to_read", itemId: "bulk-cursor", status: "open" };
|
|
92
|
+
const response = await patch("/bulk", {
|
|
93
|
+
items: [
|
|
94
|
+
{ ...item, readAt: earlier, lastReadAt: later },
|
|
95
|
+
{ ...item, readAt: later },
|
|
96
|
+
{ ...item, lastReadAt: earlier },
|
|
97
|
+
{ ...item, lastReadAt: null },
|
|
98
|
+
{ ...item, itemId: "bulk-other", status: "dismissed" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
expect(response.status).toBe(200);
|
|
102
|
+
const { items } = await response.json();
|
|
103
|
+
expect(items.map((row: { itemId: string }) => row.itemId)).toEqual([
|
|
104
|
+
"bulk-cursor",
|
|
105
|
+
"bulk-cursor",
|
|
106
|
+
"bulk-cursor",
|
|
107
|
+
"bulk-cursor",
|
|
108
|
+
"bulk-other",
|
|
109
|
+
]);
|
|
110
|
+
expect(items.map((row: { lastReadAt?: string }) => row.lastReadAt)).toEqual([
|
|
111
|
+
later,
|
|
112
|
+
later,
|
|
113
|
+
earlier,
|
|
114
|
+
undefined,
|
|
115
|
+
undefined,
|
|
116
|
+
]);
|
|
117
|
+
expect(items.slice(0, 4).every((row: { readAt: string }) => row.readAt === earlier)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects an invalid item before writing anything", async () => {
|
|
121
|
+
const response = await patch("/bulk", {
|
|
122
|
+
items: [
|
|
123
|
+
{ itemType: "to_read", itemId: "must-not-write", status: "open" },
|
|
124
|
+
{ itemType: "to_read", itemId: "invalid-cursor", status: "open", lastReadAt: "invalid" },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
expect(response.status).toBe(400);
|
|
128
|
+
expect((await listInboxState({ userId })).some((row) => row.itemId === "must-not-write")).toBe(
|
|
129
|
+
false,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("rejects empty batches and more than 200 items", async () => {
|
|
134
|
+
for (const count of [0, 201]) {
|
|
135
|
+
const response = await patch("/bulk", {
|
|
136
|
+
items: Array.from({ length: count }, (_, i) => ({
|
|
137
|
+
itemType: "to_read",
|
|
138
|
+
itemId: `limit-${i}`,
|
|
139
|
+
status: "open",
|
|
140
|
+
})),
|
|
141
|
+
});
|
|
142
|
+
expect(response.status).toBe(400);
|
|
143
|
+
}
|
|
144
|
+
expect((await listInboxState({ userId })).some((row) => row.itemId.startsWith("limit-"))).toBe(
|
|
145
|
+
false,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("rolls back earlier writes when a later upsert fails", async () => {
|
|
150
|
+
await getDbClient().run(`CREATE TRIGGER reject_inbox_state_test BEFORE INSERT ON inbox_item_state
|
|
151
|
+
WHEN NEW.itemId = 'reject-write' BEGIN SELECT RAISE(ABORT, 'test write failure'); END`);
|
|
152
|
+
try {
|
|
153
|
+
const response = await patch("/bulk", {
|
|
154
|
+
items: [
|
|
155
|
+
{ itemType: "to_read", itemId: "rolled-back", status: "open" },
|
|
156
|
+
{ itemType: "to_read", itemId: "reject-write", status: "open" },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
expect(response.status).toBe(500);
|
|
160
|
+
expect((await listInboxState({ userId })).some((row) => row.itemId === "rolled-back")).toBe(
|
|
161
|
+
false,
|
|
162
|
+
);
|
|
163
|
+
} finally {
|
|
164
|
+
await getDbClient().run("DROP TRIGGER reject_inbox_state_test");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -118,6 +118,21 @@ describe("kv MCP tools", () => {
|
|
|
118
118
|
expect(getRes.structuredContent.entry?.value).toEqual({ hello: "world" });
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
test.each([
|
|
122
|
+
"heartbeat-checklist",
|
|
123
|
+
"heartbeat-checklist:reported-failures",
|
|
124
|
+
])("kv-set refuses reserved heartbeat receipts even for lead (%s)", async (namespace) => {
|
|
125
|
+
const result = (await buildServer().set.handler(
|
|
126
|
+
{ namespace, key: "checklist-id", value: ["forged-id"] },
|
|
127
|
+
meta(lead),
|
|
128
|
+
)) as StructuredResult<{ success: boolean; message: string }>;
|
|
129
|
+
expect(result.structuredContent.success).toBe(false);
|
|
130
|
+
expect(result.structuredContent.message).toContain("reserved for heartbeat checklist receipts");
|
|
131
|
+
expect(
|
|
132
|
+
await getDbClient().get("SELECT key FROM kv_entries WHERE namespace = ?", [namespace]),
|
|
133
|
+
).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
121
136
|
test("kv-get returns entry=null for missing keys", async () => {
|
|
122
137
|
const tools = buildServer();
|
|
123
138
|
const getRes = (await tools.get.handler({ key: "nope" }, meta(agentA))) as StructuredResult<{
|