@desplega.ai/agent-swarm 1.131.0 → 1.133.1
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 +2 -2
- package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
- package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
- package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
- package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
- package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
- package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
- package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
- package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
- package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
- package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
- package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
- package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
- package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
- package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
- package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
- package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
- package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
- package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
- package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
- package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
- package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
- package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
- package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
- package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
- package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
- package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
- package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
- package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
- package/dist/cli-et1bbwqf.js +13 -0
- package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
- package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
- package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
- package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
- package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
- package/dist/cli-hkwfe3qj.js +530 -0
- package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
- package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
- package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
- package/dist/cli-pzq5rjym.js +15 -0
- package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
- package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
- package/dist/cli-qys3rhhz.js +27 -0
- package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
- package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
- package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
- package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
- package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
- package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
- package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
- package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
- package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
- package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
- package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
- package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
- package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
- package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
- package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
- package/dist/cli.js +22 -22
- package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
- package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
- package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
- package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
- package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
- package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
- package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
- package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
- package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
- package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
- package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
- package/dist/index-0gr8vd6k.js +39 -0
- package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
- package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
- package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
- package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
- package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
- package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
- package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
- package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
- package/dist/mistral-conversations-00zrp7g4.js +717 -0
- package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
- package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
- package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
- package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
- package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
- package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
- package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
- package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
- package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
- package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
- package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
- package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
- package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
- package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
- package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
- package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
- package/openapi.json +542 -16
- package/package.json +6 -6
- package/src/be/budget-refusal-notify.ts +14 -7
- package/src/be/db-queries/task-artifacts.ts +182 -0
- package/src/be/db.ts +783 -169
- package/src/be/events.ts +5 -0
- package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
- package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
- package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
- package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
- package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
- package/src/be/migrations/136_task_requester_provenance.sql +37 -0
- package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
- package/src/be/multi-runtime.ts +430 -0
- package/src/be/scripts/db.ts +43 -1
- package/src/be/scripts/extract-schema.ts +3 -1
- package/src/be/scripts/retention.ts +164 -0
- package/src/be/scripts/run-saved.ts +16 -1
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/steering.ts +1 -1
- package/src/be/swarm-config-guard.ts +18 -0
- package/src/commands/credential-wait.ts +54 -0
- package/src/commands/onboard/steps/core-credentials.tsx +1 -1
- package/src/commands/onboard/steps/custom-templates.tsx +1 -1
- package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
- package/src/commands/onboard/steps/health-check.tsx +1 -1
- package/src/commands/onboard/steps/start.tsx +1 -1
- package/src/commands/profile-sync-audit.ts +56 -0
- package/src/commands/profile-sync.ts +287 -15
- package/src/commands/provider-credentials.ts +46 -19
- package/src/commands/runner.ts +196 -33
- package/src/commands/templates.ts +22 -0
- package/src/github/handlers.ts +55 -9
- package/src/heartbeat/heartbeat.ts +71 -12
- package/src/hooks/hook.ts +49 -33
- package/src/http/active-sessions.ts +11 -1
- package/src/http/agents.ts +261 -30
- package/src/http/all-routes.ts +1 -0
- package/src/http/config.ts +13 -2
- package/src/http/core.ts +102 -4
- package/src/http/db-query-bounded.ts +313 -0
- package/src/http/db-query-shared.ts +249 -0
- package/src/http/db-query.ts +54 -42
- package/src/http/events.ts +2 -0
- package/src/http/index.ts +27 -2
- package/src/http/mcp-bridge.ts +37 -3
- package/src/http/mcp.ts +12 -0
- package/src/http/memory.ts +13 -2
- package/src/http/openapi.ts +1 -0
- package/src/http/poll.ts +70 -27
- package/src/http/route-def.ts +43 -9
- package/src/http/schedules.ts +2 -2
- package/src/http/scripts.ts +31 -4
- package/src/http/session-data.ts +44 -0
- package/src/http/tasks.ts +2 -2
- package/src/http/users.ts +74 -52
- package/src/http/webhooks.ts +5 -2
- package/src/http/workflows.ts +1 -4
- package/src/http/x.ts +14 -0
- package/src/integrations/kapso/inbound.ts +1 -1
- package/src/jira/outbound.ts +39 -8
- package/src/jira/webhook-lifecycle.ts +13 -2
- package/src/linear/outbound.ts +43 -10
- package/src/oauth/keepalive.ts +13 -2
- package/src/prompts/base-prompt.ts +1 -1
- package/src/prompts/session-templates.ts +45 -7
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-managed-models.ts +34 -10
- package/src/providers/codex-adapter.ts +6 -0
- package/src/providers/opencode-adapter.ts +6 -0
- package/src/providers/pi-mono-adapter.ts +8 -0
- package/src/providers/pricing-sources.md +24 -0
- package/src/queue-stall-alarm.ts +216 -0
- package/src/scheduler/scheduler.ts +7 -0
- package/src/script-workflows/executor.ts +2 -1
- package/src/scripts-runtime/executors/native.ts +2 -1
- package/src/scripts-runtime/executors/types.ts +5 -0
- package/src/scripts-runtime/loader.ts +5 -0
- package/src/scripts-runtime/swarm-config.ts +8 -0
- package/src/scripts-runtime/swarm-sdk.ts +7 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/tests/budget-claim-gate.test.ts +20 -0
- package/src/tests/claude-adapter.test.ts +32 -0
- package/src/tests/claude-managed-adapter.test.ts +38 -7
- package/src/tests/codex-adapter.test.ts +30 -0
- package/src/tests/create-task-validation.test.ts +128 -0
- package/src/tests/credential-wait.test.ts +67 -1
- package/src/tests/db-query.test.ts +711 -2
- package/src/tests/http-api-integration.test.ts +81 -0
- package/src/tests/http-users.test.ts +166 -1
- package/src/tests/identity-field-budget.test.ts +176 -0
- package/src/tests/mcp-tools-user.test.ts +34 -0
- package/src/tests/mcp-user-route.test.ts +17 -0
- package/src/tests/metrics-http.test.ts +44 -0
- package/src/tests/multi-runtime-registration.test.ts +2703 -0
- package/src/tests/openapi-response-contract.test.ts +24 -0
- package/src/tests/openapi-route-headers.test.ts +83 -0
- package/src/tests/opencode-adapter.test.ts +40 -0
- package/src/tests/outbound-listener-rejection.test.ts +138 -0
- package/src/tests/pi-mono-adapter.test.ts +98 -0
- package/src/tests/profile-sync-warning.test.ts +277 -0
- package/src/tests/profile-sync.test.ts +232 -6
- package/src/tests/prompt-template-session.test.ts +32 -3
- package/src/tests/queue-stall-alarm.test.ts +226 -0
- package/src/tests/requester-comms.test.ts +32 -0
- package/src/tests/runner-requester-profile.test.ts +38 -0
- package/src/tests/sandboxed-process.test.ts +45 -0
- package/src/tests/schedule-target-type.test.ts +38 -0
- package/src/tests/script-connections.test.ts +16 -3
- package/src/tests/scripts-external-api.test.ts +8 -3
- package/src/tests/scripts-http.test.ts +12 -0
- package/src/tests/scripts-retention.test.ts +384 -0
- package/src/tests/scripts-runtime-identity.test.ts +375 -0
- package/src/tests/scripts-runtime.test.ts +4 -3
- package/src/tests/sdk-allowlist.test.ts +47 -0
- package/src/tests/send-task-requested-by.test.ts +9 -0
- package/src/tests/session-costs.test.ts +506 -1
- package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
- package/src/tests/steering-core.test.ts +28 -0
- package/src/tests/task-artifacts.test.ts +386 -0
- package/src/tests/task-attachments-schema.test.ts +17 -1
- package/src/tests/task-completion-idempotency.test.ts +57 -0
- package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
- package/src/tests/update-profile-api.test.ts +67 -39
- package/src/tests/update-profile-auth.test.ts +48 -4
- package/src/tests/user-attribution-migration.test.ts +54 -0
- package/src/tests/user-identity.test.ts +72 -0
- package/src/tests/workflow-async-v2.test.ts +39 -1
- package/src/tests/workflow-http-v2.test.ts +3 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
- package/src/tests/workflow-retry-v2.test.ts +45 -1
- package/src/tests/workflow-swarm-script.test.ts +14 -5
- package/src/tools/db-query.ts +18 -8
- package/src/tools/get-task-details.ts +24 -3
- package/src/tools/manage-user.ts +36 -3
- package/src/tools/poll-task.ts +93 -14
- package/src/tools/script-common.ts +5 -0
- package/src/tools/send-task.ts +1 -4
- package/src/tools/store-progress.ts +13 -2
- package/src/tools/swarm-config/delete-config.ts +9 -0
- package/src/tools/swarm-config/set-config.ts +3 -2
- package/src/tools/task-action.ts +30 -0
- package/src/tools/task-tool-ctx.ts +8 -1
- package/src/tools/update-profile.ts +16 -21
- package/src/tools/utils.ts +5 -0
- package/src/tools/workflows/trigger-workflow.ts +7 -1
- package/src/types.ts +165 -1
- package/src/utils/github-pull-request.ts +76 -0
- package/src/utils/identity-field-budget.ts +73 -0
- package/src/utils/multi-runtime.ts +20 -0
- package/src/utils/requester-comms.ts +24 -0
- package/src/utils/sandboxed-process.ts +40 -14
- package/src/workflows/engine.ts +22 -2
- package/src/workflows/executors/swarm-script.ts +24 -1
- package/src/workflows/resume.ts +19 -11
- package/src/workflows/retry-poller.ts +1 -0
- package/src/workflows/triggers.ts +4 -0
- package/templates/schedules/daily-compounding-reflection/content.md +12 -3
- package/templates/skills/db-query-guidance/config.json +14 -0
- package/templates/skills/db-query-guidance/content.md +57 -0
- package/dist/cli-3te4jdkm.js +0 -225
- package/dist/cli-bmze986d.js +0 -7
- package/dist/cli-kh3ba2zf.js +0 -607
- package/dist/index-hp1kdbjj.js +0 -36
- package/dist/mistral-conversations-81fwav0j.js +0 -30578
- package/dist/otel-nqa12wf3.js +0 -3149
- package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
|
@@ -1,19 +1,27 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
4
|
import {
|
|
5
5
|
closeDb,
|
|
6
|
+
completeTask,
|
|
6
7
|
createAgent,
|
|
8
|
+
createScheduledTask,
|
|
7
9
|
createSessionCost,
|
|
8
10
|
createTaskExtended,
|
|
9
11
|
createUser,
|
|
12
|
+
createWorkflow,
|
|
13
|
+
createWorkflowRun,
|
|
14
|
+
deleteScheduledTask,
|
|
10
15
|
getAllSessionCosts,
|
|
16
|
+
getAttributionByPerson,
|
|
11
17
|
getDashboardCostSummary,
|
|
18
|
+
getDb,
|
|
12
19
|
getSessionCostSummary,
|
|
13
20
|
getSessionCostsByAgentId,
|
|
14
21
|
getSessionCostsByTaskId,
|
|
15
22
|
getSessionCostsFiltered,
|
|
16
23
|
initDb,
|
|
24
|
+
insertTaskAttachment,
|
|
17
25
|
UNATTRIBUTED_USER_ID,
|
|
18
26
|
} from "../be/db";
|
|
19
27
|
import type { SessionCost } from "../types";
|
|
@@ -965,6 +973,292 @@ describe("Session Costs API", () => {
|
|
|
965
973
|
expect(none.byUser.length).toBe(1);
|
|
966
974
|
expect(none.byUser[0]?.userId).toBe(null);
|
|
967
975
|
});
|
|
976
|
+
|
|
977
|
+
test("attributableCostUsd excludes structurally-human-free cost from the coverage denominator", () => {
|
|
978
|
+
const agent = createAgent({ name: "Denominator Agent", isLead: false, status: "idle" });
|
|
979
|
+
const user = createUser({ name: "Denominator Requester" });
|
|
980
|
+
const workflowUser = createUser({ name: "Workflow Schedule Requester" });
|
|
981
|
+
const humanWork = createTaskExtended("Human-requested work", { requestedByUserId: user.id });
|
|
982
|
+
// Structurally human-free: no human requester belongs on a heartbeat-checklist
|
|
983
|
+
// task by construction, even though this row carries one (a stale/inherited
|
|
984
|
+
// id) — it must be excluded from BOTH sides of the coverage ratio.
|
|
985
|
+
const heartbeat = createTaskExtended("Heartbeat checklist", {
|
|
986
|
+
taskType: "heartbeat-checklist",
|
|
987
|
+
requestedByUserId: user.id,
|
|
988
|
+
});
|
|
989
|
+
const legacyHeartbeat = createTaskExtended("Legacy heartbeat", {
|
|
990
|
+
taskType: "heartbeat",
|
|
991
|
+
requestedByUserId: user.id,
|
|
992
|
+
});
|
|
993
|
+
const tagOnlyHeartbeat = createTaskExtended("Tag-only heartbeat", {
|
|
994
|
+
tags: ["heartbeat"],
|
|
995
|
+
requestedByUserId: user.id,
|
|
996
|
+
});
|
|
997
|
+
const scheduled = createTaskExtended("Scheduled run", { source: "schedule" });
|
|
998
|
+
const scheduledChild = createTaskExtended("Autonomous schedule child", {
|
|
999
|
+
parentTaskId: scheduled.id,
|
|
1000
|
+
});
|
|
1001
|
+
const scheduledGrandchild = createTaskExtended("Autonomous schedule grandchild", {
|
|
1002
|
+
parentTaskId: scheduledChild.id,
|
|
1003
|
+
});
|
|
1004
|
+
const scheduledHumanHandoff = createTaskExtended("Schedule handed to a human", {
|
|
1005
|
+
parentTaskId: scheduled.id,
|
|
1006
|
+
requestedByUserId: user.id,
|
|
1007
|
+
});
|
|
1008
|
+
const humanScheduled = createTaskExtended("Human-created scheduled run", {
|
|
1009
|
+
source: "schedule",
|
|
1010
|
+
requestedByUserId: user.id,
|
|
1011
|
+
});
|
|
1012
|
+
const workflow = createWorkflow({
|
|
1013
|
+
name: `denominator-workflow-${crypto.randomUUID()}`,
|
|
1014
|
+
definition: { nodes: [] },
|
|
1015
|
+
});
|
|
1016
|
+
const autonomousWorkflowSchedule = createScheduledTask({
|
|
1017
|
+
name: `denominator-autonomous-workflow-schedule-${crypto.randomUUID()}`,
|
|
1018
|
+
intervalMs: 60_000,
|
|
1019
|
+
targetType: "workflow",
|
|
1020
|
+
workflowId: workflow.id,
|
|
1021
|
+
});
|
|
1022
|
+
const autonomousWorkflowRun = createWorkflowRun({
|
|
1023
|
+
id: crypto.randomUUID(),
|
|
1024
|
+
workflowId: workflow.id,
|
|
1025
|
+
triggerType: "schedule",
|
|
1026
|
+
triggerData: { scheduleId: autonomousWorkflowSchedule.id },
|
|
1027
|
+
});
|
|
1028
|
+
const autonomousWorkflowRoot = createTaskExtended("Autonomous scheduled workflow root", {
|
|
1029
|
+
source: "workflow",
|
|
1030
|
+
workflowRunId: autonomousWorkflowRun.id,
|
|
1031
|
+
});
|
|
1032
|
+
const humanWorkflowSchedule = createScheduledTask({
|
|
1033
|
+
name: `denominator-human-workflow-schedule-${crypto.randomUUID()}`,
|
|
1034
|
+
intervalMs: 60_000,
|
|
1035
|
+
targetType: "workflow",
|
|
1036
|
+
workflowId: workflow.id,
|
|
1037
|
+
createdBy: workflowUser.id,
|
|
1038
|
+
});
|
|
1039
|
+
const humanWorkflowRun = createWorkflowRun({
|
|
1040
|
+
id: crypto.randomUUID(),
|
|
1041
|
+
workflowId: workflow.id,
|
|
1042
|
+
triggerType: "schedule",
|
|
1043
|
+
triggerData: { scheduleId: humanWorkflowSchedule.id },
|
|
1044
|
+
createdBy: workflowUser.id,
|
|
1045
|
+
});
|
|
1046
|
+
const humanWorkflowRoot = createTaskExtended("Human-created scheduled workflow root", {
|
|
1047
|
+
source: "workflow",
|
|
1048
|
+
workflowRunId: humanWorkflowRun.id,
|
|
1049
|
+
requestedByUserId: workflowUser.id,
|
|
1050
|
+
});
|
|
1051
|
+
const manualWorkflowRun = createWorkflowRun({
|
|
1052
|
+
id: crypto.randomUUID(),
|
|
1053
|
+
workflowId: workflow.id,
|
|
1054
|
+
// Caller-controlled trigger data can mimic the scheduled payload; the
|
|
1055
|
+
// server-owned workflow_runs.triggerType must remain authoritative.
|
|
1056
|
+
triggerData: {
|
|
1057
|
+
triggerType: "schedule",
|
|
1058
|
+
scheduleId: autonomousWorkflowSchedule.id,
|
|
1059
|
+
scheduleName: autonomousWorkflowSchedule.name,
|
|
1060
|
+
scheduleCreatedBy: null,
|
|
1061
|
+
firedAt: new Date().toISOString(),
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
const manualWorkflowRoot = createTaskExtended("Requester-less manual workflow root", {
|
|
1065
|
+
source: "workflow",
|
|
1066
|
+
workflowRunId: manualWorkflowRun.id,
|
|
1067
|
+
});
|
|
1068
|
+
// Historical classification must not depend on the live schedule row.
|
|
1069
|
+
expect(deleteScheduledTask(autonomousWorkflowSchedule.id)).toBe(true);
|
|
1070
|
+
|
|
1071
|
+
createSessionCost({
|
|
1072
|
+
sessionId: "denom-human",
|
|
1073
|
+
taskId: humanWork.id,
|
|
1074
|
+
agentId: agent.id,
|
|
1075
|
+
totalCostUsd: 1.0,
|
|
1076
|
+
durationMs: 1000,
|
|
1077
|
+
numTurns: 1,
|
|
1078
|
+
model: "opus",
|
|
1079
|
+
});
|
|
1080
|
+
createSessionCost({
|
|
1081
|
+
sessionId: "denom-heartbeat",
|
|
1082
|
+
taskId: heartbeat.id,
|
|
1083
|
+
agentId: agent.id,
|
|
1084
|
+
totalCostUsd: 2.0,
|
|
1085
|
+
durationMs: 1000,
|
|
1086
|
+
numTurns: 1,
|
|
1087
|
+
model: "opus",
|
|
1088
|
+
});
|
|
1089
|
+
createSessionCost({
|
|
1090
|
+
sessionId: "denom-legacy-heartbeat",
|
|
1091
|
+
taskId: legacyHeartbeat.id,
|
|
1092
|
+
agentId: agent.id,
|
|
1093
|
+
totalCostUsd: 4.0,
|
|
1094
|
+
durationMs: 1000,
|
|
1095
|
+
numTurns: 1,
|
|
1096
|
+
model: "opus",
|
|
1097
|
+
});
|
|
1098
|
+
createSessionCost({
|
|
1099
|
+
sessionId: "denom-scheduled",
|
|
1100
|
+
taskId: scheduled.id,
|
|
1101
|
+
agentId: agent.id,
|
|
1102
|
+
totalCostUsd: 3.0,
|
|
1103
|
+
durationMs: 1000,
|
|
1104
|
+
numTurns: 1,
|
|
1105
|
+
model: "opus",
|
|
1106
|
+
});
|
|
1107
|
+
createSessionCost({
|
|
1108
|
+
sessionId: "denom-tag-only-heartbeat",
|
|
1109
|
+
taskId: tagOnlyHeartbeat.id,
|
|
1110
|
+
agentId: agent.id,
|
|
1111
|
+
totalCostUsd: 5.0,
|
|
1112
|
+
durationMs: 1000,
|
|
1113
|
+
numTurns: 1,
|
|
1114
|
+
model: "opus",
|
|
1115
|
+
});
|
|
1116
|
+
createSessionCost({
|
|
1117
|
+
sessionId: "denom-human-scheduled",
|
|
1118
|
+
taskId: humanScheduled.id,
|
|
1119
|
+
agentId: agent.id,
|
|
1120
|
+
totalCostUsd: 6.0,
|
|
1121
|
+
durationMs: 1000,
|
|
1122
|
+
numTurns: 1,
|
|
1123
|
+
model: "opus",
|
|
1124
|
+
});
|
|
1125
|
+
createSessionCost({
|
|
1126
|
+
sessionId: "denom-scheduled-grandchild",
|
|
1127
|
+
taskId: scheduledGrandchild.id,
|
|
1128
|
+
agentId: agent.id,
|
|
1129
|
+
totalCostUsd: 8.0,
|
|
1130
|
+
durationMs: 1000,
|
|
1131
|
+
numTurns: 1,
|
|
1132
|
+
model: "opus",
|
|
1133
|
+
});
|
|
1134
|
+
createSessionCost({
|
|
1135
|
+
sessionId: "denom-scheduled-human-handoff",
|
|
1136
|
+
taskId: scheduledHumanHandoff.id,
|
|
1137
|
+
agentId: agent.id,
|
|
1138
|
+
totalCostUsd: 9.0,
|
|
1139
|
+
durationMs: 1000,
|
|
1140
|
+
numTurns: 1,
|
|
1141
|
+
model: "opus",
|
|
1142
|
+
});
|
|
1143
|
+
createSessionCost({
|
|
1144
|
+
sessionId: "denom-autonomous-workflow-root",
|
|
1145
|
+
taskId: autonomousWorkflowRoot.id,
|
|
1146
|
+
agentId: agent.id,
|
|
1147
|
+
totalCostUsd: 10.0,
|
|
1148
|
+
durationMs: 1000,
|
|
1149
|
+
numTurns: 1,
|
|
1150
|
+
model: "opus",
|
|
1151
|
+
});
|
|
1152
|
+
createSessionCost({
|
|
1153
|
+
sessionId: "denom-human-workflow-root",
|
|
1154
|
+
taskId: humanWorkflowRoot.id,
|
|
1155
|
+
agentId: agent.id,
|
|
1156
|
+
totalCostUsd: 11.0,
|
|
1157
|
+
durationMs: 1000,
|
|
1158
|
+
numTurns: 1,
|
|
1159
|
+
model: "opus",
|
|
1160
|
+
});
|
|
1161
|
+
createSessionCost({
|
|
1162
|
+
sessionId: "denom-manual-workflow-root",
|
|
1163
|
+
taskId: manualWorkflowRoot.id,
|
|
1164
|
+
agentId: agent.id,
|
|
1165
|
+
totalCostUsd: 12.0,
|
|
1166
|
+
durationMs: 1000,
|
|
1167
|
+
numTurns: 1,
|
|
1168
|
+
model: "opus",
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const summary = getSessionCostSummary({ agentId: agent.id, groupBy: "both" });
|
|
1172
|
+
|
|
1173
|
+
expect(summary.totals.totalCostUsd).toBeCloseTo(71.0, 5);
|
|
1174
|
+
// Direct human work, a human-created schedule, and an explicitly
|
|
1175
|
+
// attributed handoff stay attributed, including a workflow root launched
|
|
1176
|
+
// by a human-created schedule. Stale heartbeat requesters, autonomous
|
|
1177
|
+
// schedule descendants, and creatorless scheduled workflow roots do not.
|
|
1178
|
+
expect(summary.totals.attributedCostUsd).toBeCloseTo(27.0, 5);
|
|
1179
|
+
// Denominator drops both heartbeat task types, the tag-only legacy
|
|
1180
|
+
// representation, autonomous scheduled cost and its grandchild, and the
|
|
1181
|
+
// creatorless scheduled workflow root (2.0 + 4.0 + 5.0 + 3.0 + 8.0 +
|
|
1182
|
+
// 10.0), leaving the human work plus the requester-less manual workflow
|
|
1183
|
+
// run in the population that could carry a human requester.
|
|
1184
|
+
expect(summary.totals.attributableCostUsd).toBeCloseTo(39.0, 5);
|
|
1185
|
+
expect(summary.totals.excludedCostUsd).toBeCloseTo(32.0, 5);
|
|
1186
|
+
expect(summary.totals.excludedTaskCount).toBe(6);
|
|
1187
|
+
|
|
1188
|
+
expect(summary.byUser.find((row) => row.userId === user.id)?.costUsd).toBeCloseTo(16.0, 5);
|
|
1189
|
+
expect(summary.byUser.find((row) => row.userId === workflowUser.id)?.costUsd).toBeCloseTo(
|
|
1190
|
+
11.0,
|
|
1191
|
+
5,
|
|
1192
|
+
);
|
|
1193
|
+
expect(summary.byUser.find((row) => row.userId === null)?.costUsd).toBeCloseTo(44.0, 5);
|
|
1194
|
+
|
|
1195
|
+
const mine = getSessionCostSummary({ agentId: agent.id, userId: user.id, groupBy: "user" });
|
|
1196
|
+
expect(mine.totals.totalCostUsd).toBeCloseTo(16.0, 5);
|
|
1197
|
+
expect(mine.byUser).toHaveLength(1);
|
|
1198
|
+
expect(mine.byUser[0]?.userId).toBe(user.id);
|
|
1199
|
+
|
|
1200
|
+
const autonomous = getSessionCostSummary({
|
|
1201
|
+
agentId: agent.id,
|
|
1202
|
+
userId: UNATTRIBUTED_USER_ID,
|
|
1203
|
+
groupBy: "user",
|
|
1204
|
+
});
|
|
1205
|
+
expect(autonomous.totals.totalCostUsd).toBeCloseTo(44.0, 5);
|
|
1206
|
+
expect(autonomous.byUser).toHaveLength(1);
|
|
1207
|
+
expect(autonomous.byUser[0]?.userId).toBe(null);
|
|
1208
|
+
|
|
1209
|
+
const attribution = getAttributionByPerson({});
|
|
1210
|
+
const workflowPerson = attribution.find((row) => row.userId === workflowUser.id);
|
|
1211
|
+
// Of the two workflow roots above, only the one launched by the
|
|
1212
|
+
// human-created schedule belongs in the per-person report.
|
|
1213
|
+
expect(workflowPerson?.problemsInitiated).toBe(1);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("inherited requesters do not end human-free propagation", () => {
|
|
1217
|
+
const agent = createAgent({
|
|
1218
|
+
name: "Inherited Requester Agent",
|
|
1219
|
+
isLead: false,
|
|
1220
|
+
status: "idle",
|
|
1221
|
+
});
|
|
1222
|
+
const user = createUser({ name: "Inherited Requester" });
|
|
1223
|
+
const heartbeat = createTaskExtended("Stale attributed heartbeat", {
|
|
1224
|
+
taskType: "heartbeat-checklist",
|
|
1225
|
+
requestedByUserId: user.id,
|
|
1226
|
+
});
|
|
1227
|
+
const inheritedChild = createTaskExtended("Autonomous heartbeat child", {
|
|
1228
|
+
parentTaskId: heartbeat.id,
|
|
1229
|
+
});
|
|
1230
|
+
const explicitHandoff = createTaskExtended("Explicit human handoff", {
|
|
1231
|
+
parentTaskId: heartbeat.id,
|
|
1232
|
+
requestedByUserId: user.id,
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
createSessionCost({
|
|
1236
|
+
sessionId: "inherited-requester-child",
|
|
1237
|
+
taskId: inheritedChild.id,
|
|
1238
|
+
agentId: agent.id,
|
|
1239
|
+
totalCostUsd: 2,
|
|
1240
|
+
durationMs: 1000,
|
|
1241
|
+
numTurns: 1,
|
|
1242
|
+
model: "opus",
|
|
1243
|
+
});
|
|
1244
|
+
createSessionCost({
|
|
1245
|
+
sessionId: "explicit-requester-handoff",
|
|
1246
|
+
taskId: explicitHandoff.id,
|
|
1247
|
+
agentId: agent.id,
|
|
1248
|
+
totalCostUsd: 3,
|
|
1249
|
+
durationMs: 1000,
|
|
1250
|
+
numTurns: 1,
|
|
1251
|
+
model: "opus",
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
const summary = getSessionCostSummary({ agentId: agent.id, groupBy: "user" });
|
|
1255
|
+
expect(summary.totals.totalCostUsd).toBe(5);
|
|
1256
|
+
expect(summary.totals.attributedCostUsd).toBe(3);
|
|
1257
|
+
expect(summary.totals.attributableCostUsd).toBe(3);
|
|
1258
|
+
expect(summary.totals.excludedCostUsd).toBe(2);
|
|
1259
|
+
expect(summary.byUser.find((row) => row.userId === user.id)?.costUsd).toBe(3);
|
|
1260
|
+
expect(summary.byUser.find((row) => row.userId === null)?.costUsd).toBe(2);
|
|
1261
|
+
});
|
|
968
1262
|
});
|
|
969
1263
|
|
|
970
1264
|
describe("Database: getDashboardCostSummary", () => {
|
|
@@ -1071,4 +1365,215 @@ describe("Session Costs API", () => {
|
|
|
1071
1365
|
expect(data.costMtd).toBeGreaterThanOrEqual(data.costToday);
|
|
1072
1366
|
});
|
|
1073
1367
|
});
|
|
1368
|
+
|
|
1369
|
+
describe("Database: getAttributionByPerson", () => {
|
|
1370
|
+
test("excludes inherited requesters below human-free roots from reach", () => {
|
|
1371
|
+
const rootAgent = createAgent({ name: "Reach Root Agent", isLead: false, status: "idle" });
|
|
1372
|
+
const inheritedAgent = createAgent({
|
|
1373
|
+
name: "Reach Inherited Agent",
|
|
1374
|
+
isLead: false,
|
|
1375
|
+
status: "idle",
|
|
1376
|
+
});
|
|
1377
|
+
const handoffAgent = createAgent({
|
|
1378
|
+
name: "Reach Handoff Agent",
|
|
1379
|
+
isLead: false,
|
|
1380
|
+
status: "idle",
|
|
1381
|
+
});
|
|
1382
|
+
const user = createUser({ name: "Reach Requester" });
|
|
1383
|
+
createTaskExtended("Human root", {
|
|
1384
|
+
requestedByUserId: user.id,
|
|
1385
|
+
agentId: rootAgent.id,
|
|
1386
|
+
source: "slack",
|
|
1387
|
+
vcsRepo: "example/human-root",
|
|
1388
|
+
});
|
|
1389
|
+
const heartbeat = createTaskExtended("Heartbeat root", {
|
|
1390
|
+
requestedByUserId: user.id,
|
|
1391
|
+
taskType: "heartbeat-checklist",
|
|
1392
|
+
});
|
|
1393
|
+
createTaskExtended("Inherited autonomous child", {
|
|
1394
|
+
parentTaskId: heartbeat.id,
|
|
1395
|
+
agentId: inheritedAgent.id,
|
|
1396
|
+
source: "jira",
|
|
1397
|
+
vcsRepo: "example/autonomous",
|
|
1398
|
+
});
|
|
1399
|
+
createTaskExtended("Explicit handoff child", {
|
|
1400
|
+
parentTaskId: heartbeat.id,
|
|
1401
|
+
requestedByUserId: user.id,
|
|
1402
|
+
agentId: handoffAgent.id,
|
|
1403
|
+
source: "linear",
|
|
1404
|
+
vcsRepo: "example/handoff",
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
const mine = getAttributionByPerson({}).find((row) => row.userId === user.id);
|
|
1408
|
+
expect(mine?.problemsInitiated).toBe(1);
|
|
1409
|
+
expect(mine?.agentsReached).toBe(2);
|
|
1410
|
+
expect(mine?.reposReached).toBe(2);
|
|
1411
|
+
expect(mine?.surfacesReached).toBe(2);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("counts root tasks only, and reach across the full task tree", () => {
|
|
1415
|
+
const agentA = createAgent({ name: "Attribution Agent A", isLead: false, status: "idle" });
|
|
1416
|
+
const agentB = createAgent({ name: "Attribution Agent B", isLead: false, status: "idle" });
|
|
1417
|
+
const user = createUser({ name: "Attribution Requester" });
|
|
1418
|
+
|
|
1419
|
+
const root = createTaskExtended("Root problem", {
|
|
1420
|
+
requestedByUserId: user.id,
|
|
1421
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
1422
|
+
agentId: agentA.id,
|
|
1423
|
+
});
|
|
1424
|
+
// Fan-out child of the same root — must NOT inflate problemsInitiated,
|
|
1425
|
+
// but DOES count toward reach (a second agent engaged).
|
|
1426
|
+
createTaskExtended("Fan-out child", {
|
|
1427
|
+
requestedByUserId: user.id,
|
|
1428
|
+
parentTaskId: root.id,
|
|
1429
|
+
agentId: agentB.id,
|
|
1430
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
1431
|
+
});
|
|
1432
|
+
// Structurally human-free despite a (stale) requester — excluded from
|
|
1433
|
+
// both problemsInitiated and reach.
|
|
1434
|
+
createTaskExtended("Heartbeat noise", {
|
|
1435
|
+
requestedByUserId: user.id,
|
|
1436
|
+
taskType: "heartbeat-checklist",
|
|
1437
|
+
});
|
|
1438
|
+
createTaskExtended("Legacy heartbeat noise", {
|
|
1439
|
+
requestedByUserId: user.id,
|
|
1440
|
+
taskType: "heartbeat",
|
|
1441
|
+
});
|
|
1442
|
+
createTaskExtended("Tag-only heartbeat noise", {
|
|
1443
|
+
requestedByUserId: user.id,
|
|
1444
|
+
tags: ["heartbeat"],
|
|
1445
|
+
});
|
|
1446
|
+
createTaskExtended("Human-created schedule", {
|
|
1447
|
+
requestedByUserId: user.id,
|
|
1448
|
+
source: "schedule",
|
|
1449
|
+
agentId: agentA.id,
|
|
1450
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
const rows = getAttributionByPerson({});
|
|
1454
|
+
const mine = rows.find((r) => r.userId === user.id);
|
|
1455
|
+
expect(mine).toBeDefined();
|
|
1456
|
+
expect(mine?.problemsInitiated).toBe(2);
|
|
1457
|
+
expect(mine?.agentsReached).toBe(2);
|
|
1458
|
+
expect(mine?.reposReached).toBe(1);
|
|
1459
|
+
expect(mine?.firstPassYield).toBe(null);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
test("counts GitHub PR and GitLab MR evidence as shipped", () => {
|
|
1463
|
+
const agent = createAgent({ name: "Shipped Agent", isLead: false, status: "idle" });
|
|
1464
|
+
const user = createUser({ name: "Shipped Requester" });
|
|
1465
|
+
|
|
1466
|
+
const shippedViaAttachment = createTaskExtended("Shipped via attachment", {
|
|
1467
|
+
requestedByUserId: user.id,
|
|
1468
|
+
});
|
|
1469
|
+
const attachmentChild = createTaskExtended("Child with shipping evidence", {
|
|
1470
|
+
parentTaskId: shippedViaAttachment.id,
|
|
1471
|
+
requestedByUserId: user.id,
|
|
1472
|
+
});
|
|
1473
|
+
insertTaskAttachment({
|
|
1474
|
+
taskId: attachmentChild.id,
|
|
1475
|
+
agentId: agent.id,
|
|
1476
|
+
name: "PR",
|
|
1477
|
+
kind: "url",
|
|
1478
|
+
url: "https://github.com/desplega-ai/agent-swarm/pull/1234",
|
|
1479
|
+
});
|
|
1480
|
+
completeTask(shippedViaAttachment.id);
|
|
1481
|
+
|
|
1482
|
+
const shippedViaOutput = createTaskExtended("Shipped via output fallback", {
|
|
1483
|
+
requestedByUserId: user.id,
|
|
1484
|
+
});
|
|
1485
|
+
completeTask(
|
|
1486
|
+
shippedViaOutput.id,
|
|
1487
|
+
"Opened https://github.com/desplega-ai/agent-swarm/pull/5678",
|
|
1488
|
+
);
|
|
1489
|
+
|
|
1490
|
+
const shippedViaGitLabAttachment = createTaskExtended("Shipped via GitLab attachment", {
|
|
1491
|
+
requestedByUserId: user.id,
|
|
1492
|
+
vcsProvider: "gitlab",
|
|
1493
|
+
});
|
|
1494
|
+
const gitLabAttachmentChild = createTaskExtended("Child with GitLab shipping evidence", {
|
|
1495
|
+
parentTaskId: shippedViaGitLabAttachment.id,
|
|
1496
|
+
requestedByUserId: user.id,
|
|
1497
|
+
});
|
|
1498
|
+
insertTaskAttachment({
|
|
1499
|
+
taskId: gitLabAttachmentChild.id,
|
|
1500
|
+
agentId: agent.id,
|
|
1501
|
+
name: "MR",
|
|
1502
|
+
kind: "url",
|
|
1503
|
+
url: "https://gitlab.example.com/group/project/-/merge_requests/1234",
|
|
1504
|
+
});
|
|
1505
|
+
completeTask(shippedViaGitLabAttachment.id);
|
|
1506
|
+
|
|
1507
|
+
const shippedViaGitLabOutput = createTaskExtended("Shipped via GitLab output", {
|
|
1508
|
+
requestedByUserId: user.id,
|
|
1509
|
+
vcsProvider: "gitlab",
|
|
1510
|
+
});
|
|
1511
|
+
completeTask(
|
|
1512
|
+
shippedViaGitLabOutput.id,
|
|
1513
|
+
"Opened https://gitlab.internal/group/project/-/merge_requests/5678",
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
const notShipped = createTaskExtended("Not shipped", { requestedByUserId: user.id });
|
|
1517
|
+
completeTask(notShipped.id, "Just some notes, no PR");
|
|
1518
|
+
|
|
1519
|
+
const rows = getAttributionByPerson({});
|
|
1520
|
+
const mine = rows.find((r) => r.userId === user.id);
|
|
1521
|
+
expect(mine?.problemsInitiated).toBe(5);
|
|
1522
|
+
expect(mine?.problemsShipped).toBe(4);
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
test("respects the date range filter", () => {
|
|
1526
|
+
const user = createUser({ name: "Date Range Requester" });
|
|
1527
|
+
const inRange = createTaskExtended("In range", { requestedByUserId: user.id });
|
|
1528
|
+
getDb()
|
|
1529
|
+
.prepare("UPDATE agent_tasks SET createdAt = ? WHERE id = ?")
|
|
1530
|
+
.run("2026-08-19T23:59:59.000Z", inRange.id);
|
|
1531
|
+
|
|
1532
|
+
const past = getAttributionByPerson({ endDate: "2026-08-18" });
|
|
1533
|
+
expect(past.find((r) => r.userId === user.id)).toBeUndefined();
|
|
1534
|
+
|
|
1535
|
+
const present = getAttributionByPerson({
|
|
1536
|
+
startDate: "2026-08-19",
|
|
1537
|
+
endDate: "2026-08-19",
|
|
1538
|
+
});
|
|
1539
|
+
expect(present.find((r) => r.userId === user.id)?.problemsInitiated).toBe(1);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test("seeds task traversal with the report's root predicates", () => {
|
|
1543
|
+
const prepareSpy = spyOn(getDb(), "prepare");
|
|
1544
|
+
try {
|
|
1545
|
+
getAttributionByPerson({ startDate: "2026-08-19", endDate: "2026-08-19" });
|
|
1546
|
+
const call = prepareSpy.mock.calls.find(([sql]) =>
|
|
1547
|
+
String(sql).includes("task_tree(rootId, taskId, output)"),
|
|
1548
|
+
);
|
|
1549
|
+
const sql = String(call?.[0] ?? "");
|
|
1550
|
+
const seed = sql.slice(0, sql.indexOf("task_tree(rootId, taskId, output)"));
|
|
1551
|
+
|
|
1552
|
+
expect(seed).toContain("selected_roots");
|
|
1553
|
+
expect(seed).toContain("t.requestedByUserId IS NOT NULL");
|
|
1554
|
+
expect(seed).toContain("t.createdAt >= ?");
|
|
1555
|
+
expect(seed).toContain("t.createdAt < ?");
|
|
1556
|
+
expect(seed).toContain("t.parentTaskId IS NULL");
|
|
1557
|
+
expect(seed).not.toContain("human_free_tasks");
|
|
1558
|
+
expect(sql).toMatch(
|
|
1559
|
+
/task_tree\(rootId, taskId, output\) AS \(\s*SELECT id, id, output\s*FROM selected_roots/,
|
|
1560
|
+
);
|
|
1561
|
+
expect(sql.match(/\?/g)).toHaveLength(2);
|
|
1562
|
+
|
|
1563
|
+
const reachCall = prepareSpy.mock.calls.find(([preparedSql]) =>
|
|
1564
|
+
String(preparedSql).includes("task_ancestry("),
|
|
1565
|
+
);
|
|
1566
|
+
const reachSql = String(reachCall?.[0] ?? "");
|
|
1567
|
+
const reachSeed = reachSql.slice(0, reachSql.indexOf("task_ancestry("));
|
|
1568
|
+
expect(reachSeed).toContain("report_tasks");
|
|
1569
|
+
expect(reachSeed).toContain("t.requestedByUserId IS NOT NULL");
|
|
1570
|
+
expect(reachSeed).toContain("t.createdAt >= ?");
|
|
1571
|
+
expect(reachSeed).toContain("t.createdAt < ?");
|
|
1572
|
+
expect(reachSql).toContain("JOIN task_ancestry child ON parent.id = child.parentTaskId");
|
|
1573
|
+
expect(reachSql.match(/\?/g)).toHaveLength(2);
|
|
1574
|
+
} finally {
|
|
1575
|
+
prepareSpy.mockRestore();
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1074
1579
|
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
clearVolatileSecretsForTesting,
|
|
4
|
+
refreshSecretScrubberCache,
|
|
5
|
+
registerVolatileSecret,
|
|
6
|
+
scrubSecrets,
|
|
7
|
+
} from "../utils/secret-scrubber";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A rejected shutdown step (or any other fire-and-forget call) can carry an
|
|
11
|
+
* SDK error whose message embeds an authorization header or token. Logging the
|
|
12
|
+
* raw rejection would put that on stdout, so every such handler converts the
|
|
13
|
+
* rejection to text and passes it through `scrubSecrets` at the egress point.
|
|
14
|
+
*
|
|
15
|
+
* The first group exercises that conversion directly. The second asserts the
|
|
16
|
+
* SIGINT/SIGTERM handlers actually use it: those live at module scope in
|
|
17
|
+
* `src/http/index.ts`, which binds a port on import, so the source is read
|
|
18
|
+
* rather than executed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SECRET = "xoxb-9911-shutdown-token-value";
|
|
22
|
+
|
|
23
|
+
const shutdownSourcePath = `${import.meta.dir}/../http/index.ts`;
|
|
24
|
+
const shutdownSource = await Bun.file(shutdownSourcePath).text();
|
|
25
|
+
|
|
26
|
+
describe("rejection log egress", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
registerVolatileSecret(SECRET, "SLACK_BOT_TOKEN");
|
|
29
|
+
refreshSecretScrubberCache();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
clearVolatileSecretsForTesting();
|
|
34
|
+
refreshSecretScrubberCache();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The exact expression used at every rejection log site in this change. */
|
|
38
|
+
const egress = (err: unknown): string =>
|
|
39
|
+
scrubSecrets(err instanceof Error ? err.message : String(err));
|
|
40
|
+
|
|
41
|
+
test("redacts a secret carried in an Error message", () => {
|
|
42
|
+
const logged = egress(new Error(`Slack API request failed: Bearer ${SECRET}`));
|
|
43
|
+
|
|
44
|
+
expect(logged).not.toContain(SECRET);
|
|
45
|
+
expect(logged).toContain("[REDACTED:SLACK_BOT_TOKEN]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("keeps the non-sensitive context observable", () => {
|
|
49
|
+
const logged = egress(new Error(`Slack API request failed: Bearer ${SECRET}`));
|
|
50
|
+
|
|
51
|
+
expect(logged).toContain("Slack API request failed");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not emit the Error object itself, only its message", () => {
|
|
55
|
+
const err = new Error("boom");
|
|
56
|
+
// A raw object would serialise fields such as `response`, `config`, or
|
|
57
|
+
// `headers` that SDK errors attach; only the message crosses the boundary.
|
|
58
|
+
expect(egress(err)).toBe("boom");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("redacts a secret carried in a non-Error rejection", () => {
|
|
62
|
+
const logged = egress(`token=${SECRET}`);
|
|
63
|
+
|
|
64
|
+
expect(logged).not.toContain(SECRET);
|
|
65
|
+
expect(logged).toContain("[REDACTED:SLACK_BOT_TOKEN]");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("handles null and undefined rejections without throwing", () => {
|
|
69
|
+
expect(egress(null)).toBe("null");
|
|
70
|
+
expect(egress(undefined)).toBe("undefined");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("shutdown signal handlers", () => {
|
|
75
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
76
|
+
test(`${signal} routes its rejection through scrubSecrets`, () => {
|
|
77
|
+
const handler = shutdownSource.match(
|
|
78
|
+
new RegExp(`process\\.on\\("${signal}",[\\s\\S]*?\\n \\}\\);`),
|
|
79
|
+
)?.[0];
|
|
80
|
+
|
|
81
|
+
expect(handler).toBeDefined();
|
|
82
|
+
expect(handler).toContain("shutdown().catch(");
|
|
83
|
+
expect(handler).toContain("scrubSecrets(");
|
|
84
|
+
// The raw rejection must not be handed to console.error.
|
|
85
|
+
expect(handler).not.toMatch(/console\.error\([^)]*,\s*err\s*\)/);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test("both handlers still call shutdown exactly once", () => {
|
|
90
|
+
expect(shutdownSource.match(/shutdown\(\)\.catch\(/g)).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
createAgent,
|
|
13
13
|
createSteeringMessage,
|
|
14
14
|
createTaskExtended,
|
|
15
|
+
createUser,
|
|
15
16
|
getChildTasks,
|
|
17
|
+
getDb,
|
|
16
18
|
getLatestLeadTaskInThread,
|
|
17
19
|
getPendingSteeringForAgent,
|
|
18
20
|
getSteeringMessageById,
|
|
@@ -246,6 +248,32 @@ describe("task steering core", () => {
|
|
|
246
248
|
});
|
|
247
249
|
});
|
|
248
250
|
|
|
251
|
+
test("automatic promotion preserves inherited requester provenance", () => {
|
|
252
|
+
const user = createUser({ name: "Steering provenance requester" });
|
|
253
|
+
const parent = createTaskExtended("heartbeat steering parent", {
|
|
254
|
+
agentId: agentIds.get("codex"),
|
|
255
|
+
taskType: "heartbeat-checklist",
|
|
256
|
+
requestedByUserId: user.id,
|
|
257
|
+
});
|
|
258
|
+
expect(startTask(parent.id)?.status).toBe("in_progress");
|
|
259
|
+
|
|
260
|
+
const result = requestSteering({
|
|
261
|
+
taskId: parent.id,
|
|
262
|
+
message: "promote autonomous steering",
|
|
263
|
+
source: "api",
|
|
264
|
+
createdByKind: "agent",
|
|
265
|
+
createdByAgentId: agentIds.get("lead"),
|
|
266
|
+
});
|
|
267
|
+
const promoted = markSteeringUndeliverable(result.steeringMessageId, "session died");
|
|
268
|
+
expect(getTaskById(promoted.promotedTaskId!)?.requestedByUserId).toBe(user.id);
|
|
269
|
+
const provenance = getDb()
|
|
270
|
+
.query<{ inherited: number }, [string]>(
|
|
271
|
+
"SELECT requestedByUserIdInherited AS inherited FROM agent_tasks WHERE id = ?",
|
|
272
|
+
)
|
|
273
|
+
.get(promoted.promotedTaskId!);
|
|
274
|
+
expect(provenance?.inherited).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
249
277
|
test("claude steer requests degrade to queue while preserving requested mode", () => {
|
|
250
278
|
const task = runningTask("claude", "claude degrade");
|
|
251
279
|
const result = requestSteering({
|