@desplega.ai/agent-swarm 1.161.0 → 1.162.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 +3 -2
- package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
- package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
- package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
- package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
- package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
- package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
- package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
- package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
- package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
- package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
- package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
- package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
- package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
- package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
- package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
- package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
- package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
- package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
- package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
- package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
- package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
- package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
- package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
- package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
- package/dist/cli-bsky8g7w.js +449 -0
- package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
- package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
- package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
- package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
- package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
- package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
- package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
- package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
- package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
- package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
- package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
- package/dist/cli-kdkwd3yr.js +615 -0
- package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
- package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
- package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
- package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
- package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
- package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
- package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
- package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
- package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
- package/dist/cli-qjjvzwzd.js +222 -0
- package/dist/cli-qrgt5bqf.js +189 -0
- package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
- package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
- package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
- package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
- package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
- package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
- package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
- package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
- package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
- package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
- package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
- package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
- package/dist/cli-wbvnn6tk.js +861 -0
- package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
- package/dist/cli-wxkkd0e0.js +33 -0
- package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
- package/dist/cli-zp38xvtm.js +9487 -0
- package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
- package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
- package/dist/cli.js +32 -25
- package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
- package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
- package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
- package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
- package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
- package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
- package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
- package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
- package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
- package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
- package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
- package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
- package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
- package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
- package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
- package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
- package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
- package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
- package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
- package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
- package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
- package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
- package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
- package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
- package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
- package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
- package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
- package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
- package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
- package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
- package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
- package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
- package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
- package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
- package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
- package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
- package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
- package/dist/rbac-roles-vpk4ebm8.js +45 -0
- package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
- package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
- package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
- package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
- package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
- package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
- package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
- package/openapi.json +491 -12
- package/package.json +13 -6
- package/src/be/api-drain.ts +184 -0
- package/src/be/db/tasks/read.ts +31 -6
- package/src/be/db/tasks/write.ts +5 -4
- package/src/be/db.ts +42 -18
- package/src/be/memory/link-resolver.ts +29 -3
- package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
- package/src/be/onboarding.ts +7 -1
- package/src/be/seed-prompt-templates.ts +1 -0
- package/src/be/swarm-config-guard.ts +35 -16
- package/src/be/task-citations.ts +11 -5
- package/src/be/users.ts +3 -0
- package/src/cli.tsx +3 -2
- package/src/comb/agent-fs.ts +39 -0
- package/src/comb/config.ts +37 -0
- package/src/comb/markers.ts +37 -0
- package/src/comb/review-batch.ts +486 -0
- package/src/comb/templates.ts +88 -0
- package/src/commands/context-preamble.ts +4 -1
- package/src/commands/hook.ts +2 -1
- package/src/commands/runner.ts +262 -68
- package/src/commands/templates.ts +10 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/fs/agent-fs-provider.ts +106 -2
- package/src/heartbeat/heartbeat.ts +4 -0
- package/src/hooks/codex-hook.ts +25 -1
- package/src/hooks/hook.ts +13 -0
- package/src/hooks/pr-body-guard.ts +983 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/assets.ts +5 -17
- package/src/http/comb.ts +131 -0
- package/src/http/core.ts +2 -1
- package/src/http/fs.ts +9 -17
- package/src/http/index.ts +23 -9
- package/src/http/onboarding.ts +21 -2
- package/src/http/poll.ts +30 -3
- package/src/http/realtime.ts +62 -0
- package/src/http/request-principal.ts +22 -0
- package/src/http/status.ts +34 -2
- package/src/kv-reserved-namespaces.ts +10 -5
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +123 -0
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/realtime/auth.ts +63 -1
- package/src/realtime/tickets.ts +52 -0
- package/src/realtime/transport.ts +51 -22
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
- package/src/telemetry-context.ts +175 -0
- package/src/telemetry-contract/EVENTS.md +81 -0
- package/src/telemetry-contract/SOURCE.json +9 -0
- package/src/telemetry-contract/catalog.json +1335 -0
- package/src/telemetry-contract/index.ts +60 -0
- package/src/telemetry-contract/types.gen.ts +1971 -0
- package/src/telemetry-identity.ts +129 -0
- package/src/telemetry-snapshot.ts +211 -0
- package/src/telemetry-trigger.ts +125 -0
- package/src/telemetry-types.typetest.ts +40 -0
- package/src/telemetry.ts +271 -67
- package/src/tests/api-drain-e2e.test.ts +249 -0
- package/src/tests/api-drain-worker-e2e.test.ts +216 -0
- package/src/tests/api-drain.test.ts +351 -0
- package/src/tests/comb-config-guard.test.ts +38 -0
- package/src/tests/comb-links.test.ts +162 -0
- package/src/tests/comb-markers.test.ts +68 -0
- package/src/tests/comb-review-batch.test.ts +770 -0
- package/src/tests/comb-status.test.ts +152 -0
- package/src/tests/db-task-read-contract.test.ts +2 -0
- package/src/tests/db-task-write-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/favorites-agent-fs-path.test.ts +226 -0
- package/src/tests/memory-link-resolver.test.ts +34 -0
- package/src/tests/onboarding.test.ts +108 -0
- package/src/tests/pi-mono-adapter.test.ts +265 -2
- package/src/tests/pr-body-leaks.test.ts +789 -0
- package/src/tests/preload.ts +8 -0
- package/src/tests/rbac-engine.test.ts +19 -0
- package/src/tests/realtime-room-auth.test.ts +104 -1
- package/src/tests/realtime-tickets.test.ts +74 -0
- package/src/tests/realtime-transport.test.ts +131 -4
- package/src/tests/runner-api-drain.test.ts +301 -0
- package/src/tests/runner-fallback-output.test.ts +61 -0
- package/src/tests/runner-fenced-json-output.test.ts +178 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/send-task-requested-by.test.ts +133 -15
- package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
- package/src/tests/telemetry-context.test.ts +314 -0
- package/src/tests/telemetry-init.test.ts +51 -19
- package/src/tests/telemetry-snapshot.test.ts +229 -0
- package/src/tests/telemetry-trigger.test.ts +200 -0
- package/src/tests/template-registry-helpers.ts +1 -0
- package/src/tests/workflow-agent-task-retry.test.ts +363 -0
- package/src/tests/workflow-definition-telemetry.test.ts +4 -4
- package/src/tools/poll-task.ts +20 -1
- package/src/tools/send-task.ts +9 -1
- package/src/tools/task-action.ts +11 -0
- package/src/types.ts +2 -1
- package/src/utils/api-drain.ts +24 -0
- package/src/utils/constants.ts +57 -16
- package/src/utils/pr-body-leaks.ts +172 -0
- package/src/utils/task-attachment-links.ts +14 -8
- package/src/workflows/recovery.ts +26 -1
- package/src/workflows/resume.ts +31 -4
- package/src/workflows/task-step-routing.ts +75 -2
- package/tsconfig.json +3 -0
- package/dist/cli-tjf4cn8z.js +0 -9119
- package/dist/cli-tsk3nz8a.js +0 -582
- package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API-server side of the telemetry identity envelope: the parts that need the
|
|
3
|
+
* DB. `src/telemetry.ts` stays DB-free so workers can import it.
|
|
4
|
+
*
|
|
5
|
+
* - `context.org_domain`: the email DOMAIN of the org's admin (never the email)
|
|
6
|
+
* - `context.user_role`: `admin` when the user's grants include everything, else `member`
|
|
7
|
+
*
|
|
8
|
+
* This file must not import `./be/db` (`be/db.ts` imports the trigger module
|
|
9
|
+
* that imports this one). It reads through the DB client and lazy-imports RBAC.
|
|
10
|
+
*/
|
|
11
|
+
import { getDbClient } from "./be/db/runtime";
|
|
12
|
+
import { setTelemetryOrgDomain } from "./telemetry";
|
|
13
|
+
import { emailDomain, type TelemetryUserRole } from "./telemetry-context";
|
|
14
|
+
|
|
15
|
+
const ROLE_TTL_MS = 60 * 60_000;
|
|
16
|
+
const ROLE_CACHE_LIMIT = 1_000;
|
|
17
|
+
const DOMAIN_RECOMPUTE_DEBOUNCE_MS = 2_000;
|
|
18
|
+
|
|
19
|
+
const roleCache = new Map<string, { role: TelemetryUserRole; at: number }>();
|
|
20
|
+
const roleLoads = new Map<string, Promise<TelemetryUserRole | undefined>>();
|
|
21
|
+
|
|
22
|
+
async function loadUserRole(userId: string): Promise<TelemetryUserRole | undefined> {
|
|
23
|
+
try {
|
|
24
|
+
// Lazy: be/rbac-roles imports be/db, which (transitively) imports this file.
|
|
25
|
+
const { getUserGrant } = await import("./be/rbac-roles");
|
|
26
|
+
const grant = await getUserGrant(userId);
|
|
27
|
+
const role: TelemetryUserRole = grant.grantsAll ? "admin" : "member";
|
|
28
|
+
if (roleCache.size >= ROLE_CACHE_LIMIT) {
|
|
29
|
+
const oldest = roleCache.keys().next().value;
|
|
30
|
+
if (oldest !== undefined) roleCache.delete(oldest);
|
|
31
|
+
}
|
|
32
|
+
roleCache.set(userId, { role, at: Date.now() });
|
|
33
|
+
return role;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load (or refresh) a user's role. Concurrent calls for one user share one lookup. */
|
|
40
|
+
export function primeUserRole(userId: string): Promise<TelemetryUserRole | undefined> {
|
|
41
|
+
const hit = roleCache.get(userId);
|
|
42
|
+
if (hit && Date.now() - hit.at < ROLE_TTL_MS) return Promise.resolve(hit.role);
|
|
43
|
+
let load = roleLoads.get(userId);
|
|
44
|
+
if (!load) {
|
|
45
|
+
load = loadUserRole(userId).finally(() => roleLoads.delete(userId));
|
|
46
|
+
roleLoads.set(userId, load);
|
|
47
|
+
}
|
|
48
|
+
return load;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Synchronous role lookup for `track()`. On a miss it starts a background load
|
|
53
|
+
* and returns undefined, so only the first event of a user can carry a null
|
|
54
|
+
* `user_role`. Emitters that are already async call `primeUserRole` first.
|
|
55
|
+
*/
|
|
56
|
+
export function cachedUserRole(userId: string): TelemetryUserRole | undefined {
|
|
57
|
+
const hit = roleCache.get(userId);
|
|
58
|
+
if (!hit || Date.now() - hit.at >= ROLE_TTL_MS) void primeUserRole(userId);
|
|
59
|
+
return hit?.role;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The email domain of the org's earliest-created active admin, falling back to
|
|
64
|
+
* the earliest active user with an email. A candidate whose address has no
|
|
65
|
+
* valid hostname (`admin@localhost`) is skipped. Undefined when none qualifies.
|
|
66
|
+
*/
|
|
67
|
+
export async function computeOrgDomain(): Promise<string | undefined> {
|
|
68
|
+
const rows = await getDbClient().query<{ email: string }>(
|
|
69
|
+
`SELECT u.email AS email
|
|
70
|
+
FROM users u
|
|
71
|
+
WHERE u.status = 'active' AND u.email IS NOT NULL AND TRIM(u.email) <> ''
|
|
72
|
+
ORDER BY EXISTS (
|
|
73
|
+
SELECT 1 FROM principal_roles pr JOIN roles r ON r.id = pr.roleId
|
|
74
|
+
WHERE pr.principalType = 'user' AND pr.principalId = u.id AND r.grantsAll = 1
|
|
75
|
+
) DESC,
|
|
76
|
+
u.createdAt ASC, u.id ASC
|
|
77
|
+
LIMIT 20`,
|
|
78
|
+
);
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const domain = emailDomain(row.email);
|
|
81
|
+
if (domain) return domain;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let persistDomain: ((domain: string | undefined) => Promise<void>) | null = null;
|
|
87
|
+
let lastPersistedDomain: string | undefined;
|
|
88
|
+
let recomputeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
89
|
+
|
|
90
|
+
/** Register how to persist `telemetry_org_domain`, so workers can read it. Set by the ticker. */
|
|
91
|
+
export function configureOrgDomainPersistence(
|
|
92
|
+
persist: ((domain: string | undefined) => Promise<void>) | null,
|
|
93
|
+
alreadyStored?: string,
|
|
94
|
+
): void {
|
|
95
|
+
persistDomain = persist;
|
|
96
|
+
lastPersistedDomain = alreadyStored;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Recompute the org domain, publish it to `track()`, and persist it when it changed. */
|
|
100
|
+
export async function recomputeOrgDomain(): Promise<string | undefined> {
|
|
101
|
+
const domain = await computeOrgDomain();
|
|
102
|
+
setTelemetryOrgDomain(domain);
|
|
103
|
+
if (persistDomain && domain !== lastPersistedDomain) {
|
|
104
|
+
await persistDomain(domain);
|
|
105
|
+
lastPersistedDomain = domain;
|
|
106
|
+
}
|
|
107
|
+
return domain;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Debounced `recomputeOrgDomain` for user create, update and delete. Never throws. */
|
|
111
|
+
export function scheduleOrgDomainRecompute(): void {
|
|
112
|
+
if (!persistDomain) return; // telemetry not started (or opted out)
|
|
113
|
+
if (recomputeTimer) clearTimeout(recomputeTimer);
|
|
114
|
+
recomputeTimer = setTimeout(() => {
|
|
115
|
+
recomputeTimer = null;
|
|
116
|
+
recomputeOrgDomain().catch(() => {});
|
|
117
|
+
}, DOMAIN_RECOMPUTE_DEBOUNCE_MS);
|
|
118
|
+
recomputeTimer.unref?.();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test-only: clear module state. */
|
|
122
|
+
export function _resetTelemetryIdentityForTests(): void {
|
|
123
|
+
roleCache.clear();
|
|
124
|
+
roleLoads.clear();
|
|
125
|
+
persistDomain = null;
|
|
126
|
+
lastPersistedDomain = undefined;
|
|
127
|
+
if (recomputeTimer) clearTimeout(recomputeTimer);
|
|
128
|
+
recomputeTimer = null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily `org.snapshot` event and the hourly telemetry ticker.
|
|
3
|
+
*
|
|
4
|
+
* A user count is org state, not a fact about an event, so it travels in one
|
|
5
|
+
* row per install per day instead of in `context` on every event. The snapshot
|
|
6
|
+
* carries counts only: no names, no emails, no user IDs.
|
|
7
|
+
*
|
|
8
|
+
* API server only. Workers never import this file.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getDbClient } from "./be/db/runtime";
|
|
12
|
+
import { isTelemetryReady, setTelemetryRoleResolver, telemetry } from "./telemetry";
|
|
13
|
+
import type { PropsFor } from "./telemetry-contract";
|
|
14
|
+
import {
|
|
15
|
+
cachedUserRole,
|
|
16
|
+
configureOrgDomainPersistence,
|
|
17
|
+
recomputeOrgDomain,
|
|
18
|
+
} from "./telemetry-identity";
|
|
19
|
+
import { scrubSecrets } from "./utils/secret-scrubber";
|
|
20
|
+
|
|
21
|
+
export type OrgSnapshot = PropsFor<"org.snapshot">;
|
|
22
|
+
|
|
23
|
+
export const LAST_SNAPSHOT_KEY = "telemetry_last_snapshot_at";
|
|
24
|
+
const ORG_DOMAIN_KEY = "telemetry_org_domain";
|
|
25
|
+
const FIRST_SNAPSHOT_DELAY_MS = 60_000;
|
|
26
|
+
const TICK_INTERVAL_MS = 60 * 60_000;
|
|
27
|
+
const SNAPSHOT_INTERVAL_MS = 24 * 60 * 60_000;
|
|
28
|
+
const ACTIVE_WINDOW_MS = 7 * 24 * 60 * 60_000;
|
|
29
|
+
|
|
30
|
+
/** Identity kinds that get their own `users_linked_*` counter. Every other kind sums into `users_linked_other`. */
|
|
31
|
+
const NAMED_LINK_KINDS = ["slack", "github", "gitlab", "linear", "jira"] as const;
|
|
32
|
+
|
|
33
|
+
type CountRow = { n: number };
|
|
34
|
+
|
|
35
|
+
async function count(sql: string, params: unknown[] = []): Promise<number> {
|
|
36
|
+
const row = await getDbClient().get<CountRow>(sql, params as never[]);
|
|
37
|
+
return Number(row?.n ?? 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read-only aggregates over the user, token, identity, task and agent tables. */
|
|
41
|
+
export async function collectOrgSnapshot(now = Date.now()): Promise<OrgSnapshot> {
|
|
42
|
+
const linked = async (kind: string) =>
|
|
43
|
+
count(
|
|
44
|
+
`SELECT COUNT(DISTINCT x.userId) AS n
|
|
45
|
+
FROM user_external_ids x JOIN users u ON u.id = x.userId
|
|
46
|
+
WHERE u.status = 'active' AND x.kind = ?`,
|
|
47
|
+
[kind],
|
|
48
|
+
);
|
|
49
|
+
const since = new Date(now - ACTIVE_WINDOW_MS).toISOString();
|
|
50
|
+
const [
|
|
51
|
+
usersTotal,
|
|
52
|
+
usersSuspended,
|
|
53
|
+
usersAdmin,
|
|
54
|
+
usersWithLogin,
|
|
55
|
+
slack,
|
|
56
|
+
github,
|
|
57
|
+
gitlab,
|
|
58
|
+
linear,
|
|
59
|
+
jira,
|
|
60
|
+
other,
|
|
61
|
+
usersActive7d,
|
|
62
|
+
agentsTotal,
|
|
63
|
+
] = await Promise.all([
|
|
64
|
+
count(`SELECT COUNT(*) AS n FROM users WHERE status = 'active'`),
|
|
65
|
+
count(`SELECT COUNT(*) AS n FROM users WHERE status = 'suspended'`),
|
|
66
|
+
count(
|
|
67
|
+
`SELECT COUNT(DISTINCT u.id) AS n
|
|
68
|
+
FROM users u
|
|
69
|
+
JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
|
|
70
|
+
JOIN roles r ON r.id = pr.roleId
|
|
71
|
+
WHERE u.status = 'active' AND r.grantsAll = 1`,
|
|
72
|
+
),
|
|
73
|
+
count(
|
|
74
|
+
`SELECT COUNT(*) AS n
|
|
75
|
+
FROM users u
|
|
76
|
+
WHERE u.status = 'active'
|
|
77
|
+
AND ((u.email IS NOT NULL AND TRIM(u.email) <> '')
|
|
78
|
+
OR EXISTS (SELECT 1 FROM user_tokens t WHERE t.userId = u.id AND t.revokedAt IS NULL))`,
|
|
79
|
+
),
|
|
80
|
+
linked("slack"),
|
|
81
|
+
linked("github"),
|
|
82
|
+
linked("gitlab"),
|
|
83
|
+
linked("linear"),
|
|
84
|
+
linked("jira"),
|
|
85
|
+
count(
|
|
86
|
+
`SELECT COUNT(DISTINCT x.userId) AS n
|
|
87
|
+
FROM user_external_ids x JOIN users u ON u.id = x.userId
|
|
88
|
+
WHERE u.status = 'active' AND x.kind NOT IN (${NAMED_LINK_KINDS.map(() => "?").join(", ")})`,
|
|
89
|
+
[...NAMED_LINK_KINDS],
|
|
90
|
+
),
|
|
91
|
+
count(
|
|
92
|
+
`SELECT COUNT(DISTINCT requestedByUserId) AS n
|
|
93
|
+
FROM agent_tasks
|
|
94
|
+
WHERE requestedByUserId IS NOT NULL AND createdAt >= ?`,
|
|
95
|
+
[since],
|
|
96
|
+
),
|
|
97
|
+
count(`SELECT COUNT(*) AS n FROM agents`),
|
|
98
|
+
]);
|
|
99
|
+
return {
|
|
100
|
+
users_total: usersTotal,
|
|
101
|
+
users_suspended: usersSuspended,
|
|
102
|
+
users_admin: usersAdmin,
|
|
103
|
+
users_with_login: usersWithLogin,
|
|
104
|
+
users_linked_slack: slack,
|
|
105
|
+
users_linked_github: github,
|
|
106
|
+
users_linked_gitlab: gitlab,
|
|
107
|
+
users_linked_linear: linear,
|
|
108
|
+
users_linked_jira: jira,
|
|
109
|
+
users_linked_other: other,
|
|
110
|
+
users_active_7d: usersActive7d,
|
|
111
|
+
agents_total: agentsTotal,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SnapshotDeps {
|
|
116
|
+
getConfig: (key: string) => Promise<string | undefined> | string | undefined;
|
|
117
|
+
setConfig: (key: string, value: string) => Promise<void> | void;
|
|
118
|
+
now?: () => number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let inFlight: Promise<boolean> | null = null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send one `org.snapshot` when 24 h have passed since the last one.
|
|
125
|
+
* `swarm_config.telemetry_last_snapshot_at` records the send time, so a restart
|
|
126
|
+
* does not send twice in a day. Returns true when an event was sent. Never
|
|
127
|
+
* throws. Sends nothing (and records nothing) when telemetry is off or not yet
|
|
128
|
+
* identified, so the next tick retries.
|
|
129
|
+
*/
|
|
130
|
+
export function emitOrgSnapshotIfDue(deps: SnapshotDeps): Promise<boolean> {
|
|
131
|
+
if (inFlight) return inFlight;
|
|
132
|
+
inFlight = (async () => {
|
|
133
|
+
try {
|
|
134
|
+
if (!isTelemetryReady()) return false;
|
|
135
|
+
const now = (deps.now ?? Date.now)();
|
|
136
|
+
const lastRaw = await deps.getConfig(LAST_SNAPSHOT_KEY);
|
|
137
|
+
const last = lastRaw ? Date.parse(lastRaw) : Number.NaN;
|
|
138
|
+
if (Number.isFinite(last) && now - last < SNAPSHOT_INTERVAL_MS) return false;
|
|
139
|
+
const snapshot = await collectOrgSnapshot(now);
|
|
140
|
+
// Record first: a crash between the two steps loses one snapshot, where the
|
|
141
|
+
// other order would send a second one on every restart loop.
|
|
142
|
+
await deps.setConfig(LAST_SNAPSHOT_KEY, new Date(now).toISOString());
|
|
143
|
+
telemetry.org("snapshot", snapshot);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(
|
|
147
|
+
"[telemetry] org.snapshot failed:",
|
|
148
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
149
|
+
);
|
|
150
|
+
return false;
|
|
151
|
+
} finally {
|
|
152
|
+
inFlight = null;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
return inFlight;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
159
|
+
let firstTimer: ReturnType<typeof setTimeout> | null = null;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Start the API server's telemetry upkeep: the role resolver for
|
|
163
|
+
* `context.user_role`, the org-domain recompute (at boot, on user changes, then
|
|
164
|
+
* hourly) and the daily `org.snapshot` (60 s after boot, then whenever the
|
|
165
|
+
* hourly tick finds 24 h have passed). One timer, no new cadence per feature.
|
|
166
|
+
* A no-op when telemetry is off.
|
|
167
|
+
*/
|
|
168
|
+
export function startTelemetryTicker(deps: SnapshotDeps): void {
|
|
169
|
+
if (!isTelemetryReady()) return;
|
|
170
|
+
stopTelemetryTicker();
|
|
171
|
+
setTelemetryRoleResolver(cachedUserRole);
|
|
172
|
+
|
|
173
|
+
const tick = async () => {
|
|
174
|
+
try {
|
|
175
|
+
await recomputeOrgDomain();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(
|
|
178
|
+
"[telemetry] org domain recompute failed:",
|
|
179
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
await emitOrgSnapshotIfDue(deps);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
Promise.resolve(deps.getConfig(ORG_DOMAIN_KEY))
|
|
186
|
+
.then((stored) => {
|
|
187
|
+
configureOrgDomainPersistence(async (domain) => {
|
|
188
|
+
// An empty value clears a stale domain, so workers stop sending it.
|
|
189
|
+
await deps.setConfig(ORG_DOMAIN_KEY, domain ?? "");
|
|
190
|
+
}, stored || undefined);
|
|
191
|
+
return recomputeOrgDomain();
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
|
|
195
|
+
firstTimer = setTimeout(() => {
|
|
196
|
+
firstTimer = null;
|
|
197
|
+
void emitOrgSnapshotIfDue(deps);
|
|
198
|
+
}, FIRST_SNAPSHOT_DELAY_MS);
|
|
199
|
+
firstTimer.unref?.();
|
|
200
|
+
tickTimer = setInterval(() => void tick(), TICK_INTERVAL_MS);
|
|
201
|
+
tickTimer.unref?.();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function stopTelemetryTicker(): void {
|
|
205
|
+
if (tickTimer) clearInterval(tickTimer);
|
|
206
|
+
if (firstTimer) clearTimeout(firstTimer);
|
|
207
|
+
tickTimer = null;
|
|
208
|
+
firstTimer = null;
|
|
209
|
+
setTelemetryRoleResolver(null);
|
|
210
|
+
configureOrgDomainPersistence(null);
|
|
211
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trigger_surface` for task and session telemetry: the surface that started
|
|
3
|
+
* the whole chain of work, resolved at the root of the `parentTaskId` chain.
|
|
4
|
+
*
|
|
5
|
+
* A Slack ask that the lead splits into 5 worker tasks (`send-task` writes
|
|
6
|
+
* `source: "mcp"`, follow-ups write `system`) is 6 Slack tasks, not 1 Slack
|
|
7
|
+
* task and 5 MCP ones. The raw per-task value is kept as `task_source`.
|
|
8
|
+
*
|
|
9
|
+
* API server only: it reads `agent_tasks`. Workers receive the resolved value
|
|
10
|
+
* from the poll response.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getDbClient } from "./be/db/runtime";
|
|
14
|
+
import { isTelemetryEnabled, telemetry } from "./telemetry";
|
|
15
|
+
import { mapTriggerSurface } from "./telemetry-context";
|
|
16
|
+
import type { PropsFor, TriggerSurface } from "./telemetry-contract";
|
|
17
|
+
import { primeUserRole } from "./telemetry-identity";
|
|
18
|
+
import { scrubSecrets } from "./utils/secret-scrubber";
|
|
19
|
+
|
|
20
|
+
const MAX_CHAIN_DEPTH = 50;
|
|
21
|
+
const CACHE_LIMIT = 10_000;
|
|
22
|
+
|
|
23
|
+
/** Task ID -> root surface. A task's root is fixed at creation, because `parentTaskId` is set once. */
|
|
24
|
+
const rootSurfaceCache = new Map<string, TriggerSurface>();
|
|
25
|
+
|
|
26
|
+
function remember(taskId: string, surface: TriggerSurface): void {
|
|
27
|
+
rootSurfaceCache.delete(taskId); // re-insert so the Map order is least-recently-used first
|
|
28
|
+
rootSurfaceCache.set(taskId, surface);
|
|
29
|
+
if (rootSurfaceCache.size > CACHE_LIMIT) {
|
|
30
|
+
const oldest = rootSurfaceCache.keys().next().value;
|
|
31
|
+
if (oldest !== undefined) rootSurfaceCache.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The surface of the root task above `taskId`.
|
|
37
|
+
*
|
|
38
|
+
* One recursive CTE walks `parentTaskId` (depth capped at 50) and the deepest
|
|
39
|
+
* row wins, so an orphan whose parent row was deleted reports the deepest row
|
|
40
|
+
* that still exists. When the task itself is gone, `fallbackSource` (the
|
|
41
|
+
* caller's own task source) is used and nothing is cached.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveTriggerSurface(
|
|
44
|
+
taskId: string,
|
|
45
|
+
fallbackSource?: string | null,
|
|
46
|
+
): Promise<TriggerSurface> {
|
|
47
|
+
const cached = rootSurfaceCache.get(taskId);
|
|
48
|
+
if (cached) {
|
|
49
|
+
remember(taskId, cached);
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
const row = await getDbClient().get<{ source: string | null }>(
|
|
53
|
+
`WITH RECURSIVE chain(id, parentTaskId, source, depth) AS (
|
|
54
|
+
SELECT id, parentTaskId, source, 0 FROM agent_tasks WHERE id = ?
|
|
55
|
+
UNION ALL
|
|
56
|
+
SELECT t.id, t.parentTaskId, t.source, c.depth + 1
|
|
57
|
+
FROM agent_tasks t JOIN chain c ON t.id = c.parentTaskId
|
|
58
|
+
WHERE c.depth < ?
|
|
59
|
+
)
|
|
60
|
+
SELECT source FROM chain ORDER BY depth DESC LIMIT 1`,
|
|
61
|
+
[taskId, MAX_CHAIN_DEPTH],
|
|
62
|
+
);
|
|
63
|
+
if (!row) return mapTriggerSurface(fallbackSource);
|
|
64
|
+
const surface = mapTriggerSurface(row.source);
|
|
65
|
+
remember(taskId, surface);
|
|
66
|
+
return surface;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type TaskTelemetryEvent =
|
|
70
|
+
| "created"
|
|
71
|
+
| "started"
|
|
72
|
+
| "claimed"
|
|
73
|
+
| "completed"
|
|
74
|
+
| "failed"
|
|
75
|
+
| "cancelled"
|
|
76
|
+
| "superseded";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A task lifecycle event as call sites build it: the catalog properties minus
|
|
80
|
+
* the two derived ones, plus the task's own `source`. `emitTaskTelemetry`
|
|
81
|
+
* renames `source` to `task_source` and adds `trigger_surface`, in one place.
|
|
82
|
+
*/
|
|
83
|
+
export type TaskTelemetryInput<S extends TaskTelemetryEvent = TaskTelemetryEvent> =
|
|
84
|
+
S extends TaskTelemetryEvent
|
|
85
|
+
? Omit<PropsFor<`task.${S}`>, "task_source" | "trigger_surface"> & {
|
|
86
|
+
source?: string | null;
|
|
87
|
+
}
|
|
88
|
+
: never;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Send a `task.*` lifecycle event with `trigger_surface` and `task_source`.
|
|
92
|
+
* Every lifecycle event of a task resolves the same task ID, so `completed`
|
|
93
|
+
* reports exactly the `trigger_surface` its `created` reported. Never throws.
|
|
94
|
+
*/
|
|
95
|
+
export async function emitTaskTelemetry<S extends TaskTelemetryEvent>(
|
|
96
|
+
event: S,
|
|
97
|
+
input: TaskTelemetryInput<S>,
|
|
98
|
+
actorUserId?: string | null,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
if (!isTelemetryEnabled()) return;
|
|
101
|
+
try {
|
|
102
|
+
const { source, ...rest } = input as TaskTelemetryInput & { taskId: string };
|
|
103
|
+
const triggerSurface = await resolveTriggerSurface(rest.taskId, source);
|
|
104
|
+
if (actorUserId) await primeUserRole(actorUserId);
|
|
105
|
+
telemetry.taskEvent(
|
|
106
|
+
event,
|
|
107
|
+
{
|
|
108
|
+
...rest,
|
|
109
|
+
task_source: mapTriggerSurface(source),
|
|
110
|
+
trigger_surface: triggerSurface,
|
|
111
|
+
} as never,
|
|
112
|
+
actorUserId ? { userId: actorUserId } : undefined,
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(
|
|
116
|
+
"[telemetry] task event failed:",
|
|
117
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test-only: clear the memo so a test can re-shape the task tree. */
|
|
123
|
+
export function _resetTriggerSurfaceCacheForTests(): void {
|
|
124
|
+
rootSurfaceCache.clear();
|
|
125
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time regression guard for `track()`'s catalog bound.
|
|
3
|
+
*
|
|
4
|
+
* Never imported and never run. `tsconfig.json` excludes `src/tests`, so a
|
|
5
|
+
* `@ts-expect-error` in a `*.test.ts` file is never checked; this file sits
|
|
6
|
+
* inside the program that `bun run tsc:check` compiles (CI: "Lint and Type
|
|
7
|
+
* Check"). Every `@ts-expect-error` below is an assertion: once `track()` stops
|
|
8
|
+
* rejecting the line under it, tsc reports an unused directive and CI fails.
|
|
9
|
+
*/
|
|
10
|
+
import { track } from "./telemetry";
|
|
11
|
+
|
|
12
|
+
const taskCreated = {
|
|
13
|
+
taskId: "task-1",
|
|
14
|
+
task_source: "mcp",
|
|
15
|
+
trigger_surface: "mcp",
|
|
16
|
+
hasParent: false,
|
|
17
|
+
has_repo: false,
|
|
18
|
+
priority: 50,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export function _trackRejectsWhatTheCatalogDoesNot(): void {
|
|
22
|
+
// Control: a catalogued event with catalogued properties compiles. If the
|
|
23
|
+
// contract types break, this line fails instead of the assertions passing.
|
|
24
|
+
track({ event: "task.created", properties: taskCreated });
|
|
25
|
+
|
|
26
|
+
track({
|
|
27
|
+
// @ts-expect-error "not.in.catalog" is not a catalogued agent-swarm event
|
|
28
|
+
event: "not.in.catalog",
|
|
29
|
+
properties: {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
track({
|
|
33
|
+
event: "task.created",
|
|
34
|
+
properties: {
|
|
35
|
+
...taskCreated,
|
|
36
|
+
// @ts-expect-error "bogus" is not a property of task.created
|
|
37
|
+
bogus: 1,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|