@desplega.ai/agent-swarm 1.113.0 → 1.115.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 +2 -2
- package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
- package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
- package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
- package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
- package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
- package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
- package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
- package/dist/cli-0pygq9r1.js +429 -0
- package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
- package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
- package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
- package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
- package/dist/cli-52fg7s7x.js +269 -0
- package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
- package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
- package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
- package/dist/cli-86fdnerz.js +405 -0
- package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
- package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
- package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
- package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
- package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
- package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
- package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
- package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
- package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
- package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
- package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
- package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
- package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
- package/dist/cli-jkhwmmgg.js +136 -0
- package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
- package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
- package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
- package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
- package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
- package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
- package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
- package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
- package/dist/cli-t0vqevnm.js +393 -0
- package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
- package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
- package/dist/cli-wh91zby6.js +187222 -0
- package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
- package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
- package/dist/cli.js +32 -15
- package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
- package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
- package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
- package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
- package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
- package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
- package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
- package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
- package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
- package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
- package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
- package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
- package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
- package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
- package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
- package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
- package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
- package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
- package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
- package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
- package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
- package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
- package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
- package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
- package/dist/rbac-roles-kfesjy9y.js +28 -0
- package/dist/rbac-roles-mwcwhbxc.js +283 -0
- package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
- package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
- package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
- package/openapi.json +1327 -20
- package/package.json +3 -3
- package/src/agentmail/handlers.ts +16 -6
- package/src/agentmail/templates.ts +25 -5
- package/src/be/audit-user.ts +28 -4
- package/src/be/db-queries/oauth.ts +42 -0
- package/src/be/db.ts +314 -28
- package/src/be/identity.ts +79 -0
- package/src/be/mcp-proxy.ts +132 -0
- package/src/be/migrations/109_rbac_roles.sql +79 -0
- package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
- package/src/be/migrations/112_script_connections_graphql.sql +96 -0
- package/src/be/migrations/113_task_routing_affinity.sql +9 -0
- package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
- package/src/be/modelsdev-cache.json +135 -0
- package/src/be/oauth-credential-bindings.ts +35 -0
- package/src/be/oauth-refresh-sweep.ts +159 -0
- package/src/be/rbac-audit.ts +52 -13
- package/src/be/rbac-roles.ts +424 -0
- package/src/be/script-connections.ts +459 -40
- package/src/be/script-credential-broker.ts +29 -3
- package/src/be/scripts/typecheck.ts +51 -7
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
- package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
- package/src/be/users.ts +24 -0
- package/src/cli.tsx +17 -0
- package/src/github/handlers.ts +10 -9
- package/src/github/templates.ts +45 -9
- package/src/gitlab/handlers.ts +20 -8
- package/src/gitlab/templates.ts +18 -6
- package/src/heartbeat/heartbeat.ts +143 -21
- package/src/http/all-routes.ts +3 -0
- package/src/http/approval-requests.ts +12 -0
- package/src/http/config.ts +44 -10
- package/src/http/core.ts +30 -3
- package/src/http/favorites.ts +1 -5
- package/src/http/index.ts +25 -1
- package/src/http/mcp-oauth.ts +2 -0
- package/src/http/mcp-servers.ts +44 -3
- package/src/http/oauth-generic.ts +105 -0
- package/src/http/poll.ts +20 -3
- package/src/http/script-connection-proxy.ts +116 -0
- package/src/http/script-connections.ts +1657 -0
- package/src/http/scripts.ts +27 -7
- package/src/http/skills.ts +11 -0
- package/src/http/tasks.ts +27 -13
- package/src/http/x.ts +6 -2
- package/src/integrations/kapso/inbound.ts +23 -6
- package/src/jira/sync.ts +111 -17
- package/src/jira/templates.ts +10 -2
- package/src/mcp-client/http-client.ts +194 -0
- package/src/oauth/app-validation.ts +16 -0
- package/src/oauth/ensure-token.ts +69 -14
- package/src/oauth/mcp-wrapper.ts +15 -0
- package/src/oauth/wrapper.ts +48 -20
- package/src/prompts/session-templates.ts +3 -3
- package/src/providers/codex-models.ts +100 -30
- package/src/providers/modelsdev-reasoning.json +51 -0
- package/src/providers/pi-mono-mcp-client.ts +2 -133
- package/src/providers/reasoning-effort.ts +11 -5
- package/src/rbac/admission.ts +68 -0
- package/src/rbac/index.ts +2 -0
- package/src/rbac/legacy-policy.ts +12 -0
- package/src/rbac/permissions.ts +48 -0
- package/src/scheduler/scheduler.ts +9 -1
- package/src/scripts-runtime/api-client.ts +151 -19
- package/src/scripts-runtime/api-types.ts +38 -6
- package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
- package/src/scripts-runtime/credential-broker/types.ts +8 -0
- package/src/scripts-runtime/ctx.ts +11 -1
- package/src/scripts-runtime/eval-harness.ts +5 -1
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -1
- package/src/scripts-runtime/mcp-client.ts +87 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +6 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
- package/src/server-user.ts +114 -59
- package/src/server.ts +12 -0
- package/src/slack/assistant.ts +14 -6
- package/src/slack/enrich.ts +17 -0
- package/src/slack/handlers.ts +8 -22
- package/src/slack/responses.ts +91 -1
- package/src/slack/thread-buffer.ts +6 -3
- package/src/slack/watcher.ts +9 -6
- package/src/tasks/worker-follow-up.ts +100 -3
- package/src/tests/agentmail-handlers.test.ts +32 -3
- package/src/tests/approval-requests.test.ts +61 -0
- package/src/tests/audit-user.test.ts +47 -1
- package/src/tests/codex-adapter.test.ts +46 -23
- package/src/tests/credential-broker.test.ts +5 -2
- package/src/tests/delete-page-tool.test.ts +197 -0
- package/src/tests/github-handlers.test.ts +28 -2
- package/src/tests/gitlab-handlers.test.ts +125 -0
- package/src/tests/heartbeat.test.ts +48 -3
- package/src/tests/identity.test.ts +117 -0
- package/src/tests/jira-sync.test.ts +99 -1
- package/src/tests/kapso-inbound.test.ts +7 -0
- package/src/tests/mcp-tools-user.test.ts +60 -6
- package/src/tests/model-control.test.ts +8 -2
- package/src/tests/oauth-credential-bindings.test.ts +490 -0
- package/src/tests/oauth-refresh-sweep.test.ts +160 -0
- package/src/tests/pool-affinity.test.ts +401 -0
- package/src/tests/prompt-template-remaining.test.ts +2 -2
- package/src/tests/rbac-admission-e2e.test.ts +386 -0
- package/src/tests/rbac-admission.test.ts +492 -0
- package/src/tests/rbac-e2e-helpers.ts +3 -0
- package/src/tests/rbac-engine.test.ts +50 -0
- package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
- package/src/tests/rbac-roles.test.ts +328 -0
- package/src/tests/reasoning-effort.test.ts +15 -2
- package/src/tests/schedule-target-type.test.ts +50 -0
- package/src/tests/scheduled-tasks.test.ts +40 -0
- package/src/tests/script-apis-mcp.test.ts +37 -13
- package/src/tests/script-connections-http.test.ts +1158 -0
- package/src/tests/script-connections-mcp.test.ts +894 -0
- package/src/tests/script-connections.test.ts +754 -6
- package/src/tests/scripts-external-api.test.ts +49 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
- package/src/tests/scripts-runtime.test.ts +291 -0
- package/src/tests/seed-scripts.test.ts +36 -0
- package/src/tests/slack-identity-resolution.test.ts +37 -3
- package/src/tests/slack-inline-output.test.ts +65 -0
- package/src/tests/slack-thread-buffer.test.ts +28 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tests/user-token-rest-auth.test.ts +140 -1
- package/src/tests/workflow-swarm-script.test.ts +44 -0
- package/src/tests/workflow-triggers-v2.test.ts +300 -1
- package/src/tools/credential-bindings/tool.ts +277 -18
- package/src/tools/delete-page.ts +145 -0
- package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
- package/src/tools/request-human-input.ts +2 -0
- package/src/tools/resolve-user.ts +77 -27
- package/src/tools/script-apis.ts +62 -3
- package/src/tools/script-connections/tool.ts +275 -31
- package/src/tools/send-task.ts +15 -1
- package/src/tools/task-action.ts +34 -2
- package/src/tools/templates.ts +53 -1
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +2 -0
- package/src/tools/workflows/create-workflow.ts +8 -1
- package/src/tools/workflows/update-workflow.ts +11 -2
- package/src/types.ts +90 -11
- package/src/utils/scoped-resource.ts +25 -0
- package/src/workflows/executors/human-in-the-loop.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +7 -1
- package/src/workflows/triggers.ts +146 -18
- package/templates/skills/swarm-scripts/content.md +33 -8
- package/dist/cli-bgef578c.js +0 -771
- package/dist/cli-c9mtm09b.js +0 -43
- package/dist/cli-ceewqajw.js +0 -1698
- package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
- package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
- package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
- package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
- package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
- package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
- package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
- package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
- package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import {
|
|
6
|
+
closeDb,
|
|
7
|
+
createAgent,
|
|
8
|
+
createPage,
|
|
9
|
+
createPageVersion,
|
|
10
|
+
createUser,
|
|
11
|
+
getDb,
|
|
12
|
+
getPage,
|
|
13
|
+
getPageVersions,
|
|
14
|
+
initDb,
|
|
15
|
+
listUserFavorites,
|
|
16
|
+
setUserFavorite,
|
|
17
|
+
upsertKv,
|
|
18
|
+
} from "../be/db";
|
|
19
|
+
import { registerDeletePageTool } from "../tools/delete-page";
|
|
20
|
+
|
|
21
|
+
const TEST_DB_PATH = "./test-delete-page-tool.sqlite";
|
|
22
|
+
|
|
23
|
+
type RegisteredTool = {
|
|
24
|
+
handler: (args: unknown, extra: unknown) => Promise<CallToolResult>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function buildServer() {
|
|
28
|
+
const server = new McpServer({ name: "delete-page-test", version: "1.0.0" });
|
|
29
|
+
registerDeletePageTool(server);
|
|
30
|
+
const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
31
|
+
._registeredTools;
|
|
32
|
+
const tool = tools["delete-page"];
|
|
33
|
+
if (!tool) throw new Error("delete-page tool not registered");
|
|
34
|
+
return tool;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function callDeletePage(args: Record<string, unknown>, callerAgentId?: string) {
|
|
38
|
+
const tool = buildServer();
|
|
39
|
+
return tool.handler(args, {
|
|
40
|
+
sessionId: "test-session",
|
|
41
|
+
requestInfo: callerAgentId ? { headers: { "x-agent-id": callerAgentId } } : { headers: {} },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makePage(agentId: string, slug: string, title = slug) {
|
|
46
|
+
return createPage({
|
|
47
|
+
agentId,
|
|
48
|
+
slug,
|
|
49
|
+
title,
|
|
50
|
+
contentType: "text/html",
|
|
51
|
+
authMode: "public",
|
|
52
|
+
body: `<h1>${title}</h1>`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let ownerId: string;
|
|
57
|
+
let otherAgentId: string;
|
|
58
|
+
let leadId: string;
|
|
59
|
+
|
|
60
|
+
describe("delete-page MCP tool", () => {
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
63
|
+
try {
|
|
64
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
initDb(TEST_DB_PATH);
|
|
68
|
+
ownerId = createAgent({ name: "delete-page-owner", isLead: false, status: "idle" }).id;
|
|
69
|
+
otherAgentId = createAgent({
|
|
70
|
+
name: "delete-page-other",
|
|
71
|
+
isLead: false,
|
|
72
|
+
status: "idle",
|
|
73
|
+
}).id;
|
|
74
|
+
leadId = createAgent({ name: "delete-page-lead", isLead: true, status: "idle" }).id;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
closeDb();
|
|
79
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
80
|
+
try {
|
|
81
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("owner can delete by pageId and receives deleted page metadata", async () => {
|
|
87
|
+
const page = makePage(ownerId, "owner-id-delete", "Owner Delete");
|
|
88
|
+
|
|
89
|
+
const result = await callDeletePage({ pageId: page.id }, ownerId);
|
|
90
|
+
const sc = result.structuredContent as {
|
|
91
|
+
success: boolean;
|
|
92
|
+
deletedPage?: { id: string; slug: string; title: string };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
expect(sc.success).toBe(true);
|
|
96
|
+
expect(sc.deletedPage).toEqual({
|
|
97
|
+
id: page.id,
|
|
98
|
+
slug: "owner-id-delete",
|
|
99
|
+
title: "Owner Delete",
|
|
100
|
+
});
|
|
101
|
+
expect(getPage(page.id)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("slug deletes only from the caller's page namespace", async () => {
|
|
105
|
+
const ownerPage = makePage(ownerId, "shared-slug", "Owner Shared Slug");
|
|
106
|
+
const otherPage = makePage(otherAgentId, "shared-slug", "Other Shared Slug");
|
|
107
|
+
|
|
108
|
+
const result = await callDeletePage({ slug: "shared-slug" }, ownerId);
|
|
109
|
+
const sc = result.structuredContent as { success: boolean; deletedPage?: { id: string } };
|
|
110
|
+
|
|
111
|
+
expect(sc.success).toBe(true);
|
|
112
|
+
expect(sc.deletedPage?.id).toBe(ownerPage.id);
|
|
113
|
+
expect(getPage(ownerPage.id)).toBeNull();
|
|
114
|
+
expect(getPage(otherPage.id)).not.toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("lead can delete another agent's page by pageId", async () => {
|
|
118
|
+
const page = makePage(ownerId, "lead-delete", "Lead Delete");
|
|
119
|
+
|
|
120
|
+
const result = await callDeletePage({ pageId: page.id }, leadId);
|
|
121
|
+
const sc = result.structuredContent as { success: boolean };
|
|
122
|
+
|
|
123
|
+
expect(sc.success).toBe(true);
|
|
124
|
+
expect(getPage(page.id)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("non-owner non-lead gets a clear permission error and page remains", async () => {
|
|
128
|
+
const page = makePage(ownerId, "deny-delete", "Deny Delete");
|
|
129
|
+
|
|
130
|
+
const result = await callDeletePage({ pageId: page.id }, otherAgentId);
|
|
131
|
+
const sc = result.structuredContent as { success: boolean; message: string };
|
|
132
|
+
|
|
133
|
+
expect(sc.success).toBe(false);
|
|
134
|
+
expect(sc.message).toBe("Only the lead or page owner can delete pages.");
|
|
135
|
+
expect(getPage(page.id)).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("delete cascades versions and removes page-scoped KV and favorites", async () => {
|
|
139
|
+
const page = makePage(ownerId, "cascade-delete", "Cascade Delete");
|
|
140
|
+
createPageVersion({
|
|
141
|
+
pageId: page.id,
|
|
142
|
+
version: 1,
|
|
143
|
+
snapshot: {
|
|
144
|
+
title: page.title,
|
|
145
|
+
description: page.description,
|
|
146
|
+
contentType: page.contentType,
|
|
147
|
+
authMode: page.authMode,
|
|
148
|
+
body: page.body,
|
|
149
|
+
needsCredentials: page.needsCredentials,
|
|
150
|
+
},
|
|
151
|
+
changedByAgentId: ownerId,
|
|
152
|
+
});
|
|
153
|
+
upsertKv({
|
|
154
|
+
namespace: `task:page:${page.id}`,
|
|
155
|
+
key: "state",
|
|
156
|
+
value: { ok: true },
|
|
157
|
+
valueType: "json",
|
|
158
|
+
});
|
|
159
|
+
const user = createUser({ name: "Delete Page Favorite User" });
|
|
160
|
+
setUserFavorite({
|
|
161
|
+
userId: user.id,
|
|
162
|
+
itemType: "page",
|
|
163
|
+
itemId: page.id,
|
|
164
|
+
favorite: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await callDeletePage({ pageId: page.id }, ownerId);
|
|
168
|
+
const sc = result.structuredContent as { success: boolean };
|
|
169
|
+
|
|
170
|
+
expect(sc.success).toBe(true);
|
|
171
|
+
expect(getPageVersions(page.id)).toHaveLength(0);
|
|
172
|
+
expect(
|
|
173
|
+
getDb()
|
|
174
|
+
.prepare<{ n: number }, [string]>(
|
|
175
|
+
"SELECT COUNT(*) AS n FROM kv_entries WHERE namespace = ?",
|
|
176
|
+
)
|
|
177
|
+
.get(`task:page:${page.id}`)?.n,
|
|
178
|
+
).toBe(0);
|
|
179
|
+
expect(listUserFavorites({ userId: user.id, itemType: "page" })).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("missing selector or missing agent id returns success false", async () => {
|
|
183
|
+
const noSelector = await callDeletePage({}, ownerId);
|
|
184
|
+
expect((noSelector.structuredContent as { success: boolean; message: string }).success).toBe(
|
|
185
|
+
false,
|
|
186
|
+
);
|
|
187
|
+
expect((noSelector.structuredContent as { message: string }).message).toBe(
|
|
188
|
+
"Either pageId or slug must be provided.",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const noAgent = await callDeletePage({ pageId: "missing" });
|
|
192
|
+
expect((noAgent.structuredContent as { success: boolean; message: string }).success).toBe(
|
|
193
|
+
false,
|
|
194
|
+
);
|
|
195
|
+
expect((noAgent.structuredContent as { message: string }).message).toContain("Agent ID");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -119,11 +119,11 @@ function makeCommentEvent(senderLogin: string, body: string): CommentEvent {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function makeReviewEvent(senderLogin: string): PullRequestReviewEvent {
|
|
122
|
+
function makeReviewEvent(senderLogin: string, reviewId = 1): PullRequestReviewEvent {
|
|
123
123
|
return {
|
|
124
124
|
action: "submitted",
|
|
125
125
|
review: {
|
|
126
|
-
id:
|
|
126
|
+
id: reviewId,
|
|
127
127
|
body: "Looks good",
|
|
128
128
|
state: "approved",
|
|
129
129
|
html_url: "https://github.com/test/repo/pull/99#pullrequestreview-1",
|
|
@@ -153,6 +153,13 @@ function getMappedUserTaskCount(userId: string): number {
|
|
|
153
153
|
return row?.n ?? 0;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function getLatestTaskText(): string | null {
|
|
157
|
+
const row = getDb()
|
|
158
|
+
.prepare<{ task: string }, []>("SELECT task FROM agent_tasks ORDER BY rowid DESC LIMIT 1")
|
|
159
|
+
.get();
|
|
160
|
+
return row?.task ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
// ── Known sender → mapped requestedByUserId, no unmapped writes ──
|
|
157
164
|
|
|
158
165
|
describe("known github sender", () => {
|
|
@@ -216,6 +223,17 @@ describe("known github sender", () => {
|
|
|
216
223
|
expect(getKv(UNMAPPED_NAMESPACE, "reviewer:meta")).toBeNull();
|
|
217
224
|
expect(getKv(UNMAPPED_NAMESPACE, "reviewer:count")).toBeNull();
|
|
218
225
|
});
|
|
226
|
+
|
|
227
|
+
test("review event from mapped user renders the resolved identity pair, never the raw login", async () => {
|
|
228
|
+
const user = createUser({ name: "Pair Reviewer", email: "pair-reviewer@example.com" });
|
|
229
|
+
linkIdentity(user.id, "github", "pair-reviewer", SYSTEM_ACTOR);
|
|
230
|
+
|
|
231
|
+
const result = await handlePullRequestReview(makeReviewEvent("pair-reviewer", 1001));
|
|
232
|
+
expect(result.created).toBe(true);
|
|
233
|
+
|
|
234
|
+
const text = getLatestTaskText();
|
|
235
|
+
expect(text).toContain("Pair Reviewer (github:pair-reviewer)");
|
|
236
|
+
});
|
|
219
237
|
});
|
|
220
238
|
|
|
221
239
|
// ── Unknown sender → unmapped kv tracker ──
|
|
@@ -290,6 +308,14 @@ describe("unknown github sender", () => {
|
|
|
290
308
|
const metaValue = meta?.value as { sampleContext: string };
|
|
291
309
|
expect(metaValue.sampleContext.length).toBeLessThanOrEqual(100);
|
|
292
310
|
});
|
|
311
|
+
|
|
312
|
+
test("review event from unknown user renders the UNKNOWN sentinel, never a bare login", async () => {
|
|
313
|
+
const result = await handlePullRequestReview(makeReviewEvent("sentinel-ghost", 1002));
|
|
314
|
+
expect(result.created).toBe(true);
|
|
315
|
+
|
|
316
|
+
const text = getLatestTaskText();
|
|
317
|
+
expect(text).toContain("github:sentinel-ghost (unknown user)");
|
|
318
|
+
});
|
|
293
319
|
});
|
|
294
320
|
|
|
295
321
|
// ── Negative: no email-enrichment helper exists for GitHub ──
|
|
@@ -979,5 +979,130 @@ describe("identity resolution — Note handler", () => {
|
|
|
979
979
|
expect((meta?.sampleContext as string).length).toBeLessThanOrEqual(100);
|
|
980
980
|
expect((meta?.sampleContext as string).startsWith(`@${GITLAB_BOT_NAME}`)).toBe(true);
|
|
981
981
|
expect(getUnmappedCount("noteghost")).toBe(1);
|
|
982
|
+
|
|
983
|
+
// The dead-coded `_requestedByUserId` is now wired: undefined for an
|
|
984
|
+
// unresolvable sender, and the UNKNOWN sentinel (never the raw
|
|
985
|
+
// username) is rendered as the comment author in the task text.
|
|
986
|
+
const task = getTaskById(result.taskId!);
|
|
987
|
+
expect(task?.requestedByUserId).toBeFalsy();
|
|
988
|
+
expect(task?.task).toContain("gitlab:noteghost (unknown user)");
|
|
989
|
+
expect(task?.task).not.toContain("Note Ghost");
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("known GitLab user commenting -> requestedByUserId populated + rendered pair, never the raw username", async () => {
|
|
993
|
+
const known = createUser({ name: "Known Commenter" });
|
|
994
|
+
linkIdentity(known.id, "gitlab", "knowncommenter", { kind: "system", id: "test" });
|
|
995
|
+
|
|
996
|
+
const event = makeNoteEvent({
|
|
997
|
+
user: { id: 32, name: "Known Commenter", username: "knowncommenter", avatar_url: "" },
|
|
998
|
+
object_attributes: {
|
|
999
|
+
id: 961,
|
|
1000
|
+
note: `@${GITLAB_BOT_NAME} please take a look`,
|
|
1001
|
+
noteable_type: "MergeRequest",
|
|
1002
|
+
noteable_id: 101,
|
|
1003
|
+
url: "https://gitlab.com/group/project/-/merge_requests/2#note_961",
|
|
1004
|
+
author_id: 32,
|
|
1005
|
+
type: null,
|
|
1006
|
+
},
|
|
1007
|
+
merge_request: {
|
|
1008
|
+
id: 101,
|
|
1009
|
+
iid: 961,
|
|
1010
|
+
title: "Known Commenter MR",
|
|
1011
|
+
description: "",
|
|
1012
|
+
state: "opened",
|
|
1013
|
+
action: "open",
|
|
1014
|
+
source_branch: "feat-knowncommenter",
|
|
1015
|
+
target_branch: "main",
|
|
1016
|
+
url: "https://gitlab.com/group/project/-/merge_requests/961",
|
|
1017
|
+
last_commit: null,
|
|
1018
|
+
author_id: 32,
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
const result = await handleNote(event);
|
|
1023
|
+
expect(result.created).toBe(true);
|
|
1024
|
+
|
|
1025
|
+
const task = getTaskById(result.taskId!);
|
|
1026
|
+
expect(task?.requestedByUserId).toBe(known.id);
|
|
1027
|
+
expect(task?.task).toContain("Known Commenter (gitlab:knowncommenter)");
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
describe("identity resolution — Pipeline handler", () => {
|
|
1032
|
+
test("known GitLab user triggers pipeline -> requestedByUserId populated on the task", async () => {
|
|
1033
|
+
const known = createUser({ name: "Pipeline Trigger" });
|
|
1034
|
+
linkIdentity(known.id, "gitlab", "pipelinetrigger", { kind: "system", id: "test" });
|
|
1035
|
+
|
|
1036
|
+
createTaskExtended("[GitLab MR #970] Pipeline identity test", {
|
|
1037
|
+
source: "gitlab",
|
|
1038
|
+
vcsProvider: "gitlab",
|
|
1039
|
+
vcsRepo: "group/project",
|
|
1040
|
+
vcsEventType: "merge_request",
|
|
1041
|
+
vcsNumber: 970,
|
|
1042
|
+
vcsUrl: "https://gitlab.com/group/project/-/merge_requests/970",
|
|
1043
|
+
agentId: "lead-gl-001",
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const event = makePipelineEvent({
|
|
1047
|
+
user: { id: 41, name: "Pipeline Trigger", username: "pipelinetrigger", avatar_url: "" },
|
|
1048
|
+
object_attributes: {
|
|
1049
|
+
id: 500,
|
|
1050
|
+
ref: "feature",
|
|
1051
|
+
status: "failed",
|
|
1052
|
+
source: "push",
|
|
1053
|
+
detailed_status: "failed",
|
|
1054
|
+
},
|
|
1055
|
+
merge_request: {
|
|
1056
|
+
id: 200,
|
|
1057
|
+
iid: 970,
|
|
1058
|
+
title: "MR with known pipeline trigger",
|
|
1059
|
+
url: "https://gitlab.com/group/project/-/merge_requests/970",
|
|
1060
|
+
source_branch: "feature",
|
|
1061
|
+
target_branch: "main",
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
const result = await handlePipeline(event);
|
|
1066
|
+
expect(result.created).toBe(true);
|
|
1067
|
+
|
|
1068
|
+
const task = getTaskById(result.taskId!);
|
|
1069
|
+
expect(task?.requestedByUserId).toBe(known.id);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("unknown GitLab user triggers pipeline -> requestedByUserId stays undefined", async () => {
|
|
1073
|
+
createTaskExtended("[GitLab MR #971] Pipeline identity test", {
|
|
1074
|
+
source: "gitlab",
|
|
1075
|
+
vcsProvider: "gitlab",
|
|
1076
|
+
vcsRepo: "group/project",
|
|
1077
|
+
vcsEventType: "merge_request",
|
|
1078
|
+
vcsNumber: 971,
|
|
1079
|
+
vcsUrl: "https://gitlab.com/group/project/-/merge_requests/971",
|
|
1080
|
+
agentId: "lead-gl-001",
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const event = makePipelineEvent({
|
|
1084
|
+
user: { id: 42, name: "Pipeline Ghost", username: "pipelineghost", avatar_url: "" },
|
|
1085
|
+
object_attributes: {
|
|
1086
|
+
id: 501,
|
|
1087
|
+
ref: "feature",
|
|
1088
|
+
status: "failed",
|
|
1089
|
+
source: "push",
|
|
1090
|
+
detailed_status: "failed",
|
|
1091
|
+
},
|
|
1092
|
+
merge_request: {
|
|
1093
|
+
id: 201,
|
|
1094
|
+
iid: 971,
|
|
1095
|
+
title: "MR with unknown pipeline trigger",
|
|
1096
|
+
url: "https://gitlab.com/group/project/-/merge_requests/971",
|
|
1097
|
+
source_branch: "feature",
|
|
1098
|
+
target_branch: "main",
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
const result = await handlePipeline(event);
|
|
1103
|
+
expect(result.created).toBe(true);
|
|
1104
|
+
|
|
1105
|
+
const task = getTaskById(result.taskId!);
|
|
1106
|
+
expect(task?.requestedByUserId).toBeFalsy();
|
|
982
1107
|
});
|
|
983
1108
|
});
|
|
@@ -559,7 +559,7 @@ describe("Heartbeat Triage", () => {
|
|
|
559
559
|
expect(affected.length).toBe(0);
|
|
560
560
|
});
|
|
561
561
|
|
|
562
|
-
test("auto-fails in_progress task with no session and
|
|
562
|
+
test("auto-fails in_progress task with no session and pins retry to the recovered agent", async () => {
|
|
563
563
|
const agent = createAgent({ name: "dead-worker", isLead: false, status: "busy" });
|
|
564
564
|
const task = createTaskExtended("Interrupted task", { agentId: agent.id });
|
|
565
565
|
startTask(task.id);
|
|
@@ -586,8 +586,13 @@ describe("Heartbeat Triage", () => {
|
|
|
586
586
|
expect(retryTask).not.toBeNull();
|
|
587
587
|
expect(retryTask!.parentTaskId).toBe(task.id);
|
|
588
588
|
expect(retryTask!.task).toBe(task.task);
|
|
589
|
-
//
|
|
590
|
-
|
|
589
|
+
// Routing affinity (Phase 3): the failed task frees up the agent's only
|
|
590
|
+
// slot, so it looks recoverable (row exists, not offline, has capacity)
|
|
591
|
+
// by retry-creation time — the retry pins back to it instead of the
|
|
592
|
+
// role-blind pool.
|
|
593
|
+
expect(retryTask!.status).toBe("pending");
|
|
594
|
+
expect(retryTask!.agentId).toBe(agent.id);
|
|
595
|
+
expect(retryTask!.routingAffinity?.sourceAgentId).toBe(agent.id);
|
|
591
596
|
|
|
592
597
|
// Verify retry has correct tags
|
|
593
598
|
const retryRow = getDb()
|
|
@@ -596,6 +601,46 @@ describe("Heartbeat Triage", () => {
|
|
|
596
601
|
const tags = JSON.parse(retryRow.tags);
|
|
597
602
|
expect(tags).toContain("reboot-retry");
|
|
598
603
|
expect(tags).toContain("auto-generated");
|
|
604
|
+
expect(tags).toContain("reboot-retry-pin");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("falls back to an affinity-stamped pool retry when the agent is at capacity", async () => {
|
|
608
|
+
const agent = createAgent({
|
|
609
|
+
name: "full-worker",
|
|
610
|
+
isLead: false,
|
|
611
|
+
status: "busy",
|
|
612
|
+
maxTasks: 1,
|
|
613
|
+
});
|
|
614
|
+
const task = createTaskExtended("Interrupted task", { agentId: agent.id });
|
|
615
|
+
startTask(task.id);
|
|
616
|
+
// A second in-progress task with a LIVE session survives the same sweep
|
|
617
|
+
// (session-exists → skip, never reaped) and keeps the agent at capacity
|
|
618
|
+
// by the time `task`'s retry is evaluated — so `task`'s retry does NOT
|
|
619
|
+
// look recoverable and must fall to the affinity-gated pool.
|
|
620
|
+
const other = createTaskExtended("Other in-progress task", { agentId: agent.id });
|
|
621
|
+
startTask(other.id);
|
|
622
|
+
insertActiveSession({ agentId: agent.id, taskId: other.id, triggerType: "task_assigned" });
|
|
623
|
+
|
|
624
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
625
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [past, task.id]);
|
|
626
|
+
|
|
627
|
+
await runRebootSweep();
|
|
628
|
+
|
|
629
|
+
const affected = getRebootAffectedTasks();
|
|
630
|
+
expect(affected.length).toBe(1);
|
|
631
|
+
const retryTask = getTaskById(affected[0]!.retryTaskId!);
|
|
632
|
+
expect(retryTask).not.toBeNull();
|
|
633
|
+
// No agentId → falls to the pool as "unassigned", but still carries the
|
|
634
|
+
// routing-affinity snapshot so it's gated (not role-blind).
|
|
635
|
+
expect(retryTask!.status).toBe("unassigned");
|
|
636
|
+
expect(retryTask!.agentId).toBeNull();
|
|
637
|
+
expect(retryTask!.routingAffinity?.sourceAgentId).toBe(agent.id);
|
|
638
|
+
|
|
639
|
+
const retryRow = getDb()
|
|
640
|
+
.query("SELECT tags FROM agent_tasks WHERE id = ?")
|
|
641
|
+
.get(affected[0]!.retryTaskId!) as { tags: string };
|
|
642
|
+
const tags = JSON.parse(retryRow.tags);
|
|
643
|
+
expect(tags).not.toContain("reboot-retry-pin");
|
|
599
644
|
});
|
|
600
645
|
|
|
601
646
|
test("skips in_progress task that has an active session", async () => {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createUser, initDb } from "../be/db";
|
|
4
|
+
import { renderIdentity, resolveIdentity, resolveIdentityByEmail } from "../be/identity";
|
|
5
|
+
import { type IdentityActor, linkIdentity } from "../be/users";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = "./test-identity.sqlite";
|
|
8
|
+
|
|
9
|
+
const SYSTEM_ACTOR: IdentityActor = { kind: "system", id: "test" };
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
13
|
+
try {
|
|
14
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
|
17
|
+
closeDb();
|
|
18
|
+
initDb(TEST_DB_PATH);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
closeDb();
|
|
23
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
24
|
+
try {
|
|
25
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("resolveIdentity", () => {
|
|
31
|
+
test("resolved: linked (kind, externalId) returns the user", () => {
|
|
32
|
+
const user = createUser({ name: "Luis", email: "luis@example.com" });
|
|
33
|
+
linkIdentity(user.id, "slack", "U016H7XKZGS", SYSTEM_ACTOR);
|
|
34
|
+
|
|
35
|
+
const resolution = resolveIdentity("slack", "U016H7XKZGS");
|
|
36
|
+
expect(resolution).toEqual({
|
|
37
|
+
status: "resolved",
|
|
38
|
+
kind: "slack",
|
|
39
|
+
externalId: "U016H7XKZGS",
|
|
40
|
+
userId: user.id,
|
|
41
|
+
name: "Luis",
|
|
42
|
+
email: "luis@example.com",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("unknown: unlinked (kind, externalId) returns the sentinel value, never a name", () => {
|
|
47
|
+
const resolution = resolveIdentity("slack", "U_DOES_NOT_EXIST");
|
|
48
|
+
expect(resolution).toEqual({
|
|
49
|
+
status: "unknown",
|
|
50
|
+
kind: "slack",
|
|
51
|
+
externalId: "U_DOES_NOT_EXIST",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("is provider-agnostic — any kind string works identically", () => {
|
|
56
|
+
const user = createUser({ name: "Jira Reporter", email: "jira-reporter@example.com" });
|
|
57
|
+
linkIdentity(user.id, "jira", "5b10a2844c20165700ede21g", SYSTEM_ACTOR);
|
|
58
|
+
|
|
59
|
+
const resolution = resolveIdentity("jira", "5b10a2844c20165700ede21g");
|
|
60
|
+
expect(resolution.status).toBe("resolved");
|
|
61
|
+
expect(resolution.status === "resolved" && resolution.userId).toBe(user.id);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveIdentityByEmail", () => {
|
|
66
|
+
test("resolved: known email returns the user with kind 'email'", () => {
|
|
67
|
+
const user = createUser({ name: "Alberto", email: "alberto@example.com" });
|
|
68
|
+
|
|
69
|
+
const resolution = resolveIdentityByEmail("alberto@example.com");
|
|
70
|
+
expect(resolution).toEqual({
|
|
71
|
+
status: "resolved",
|
|
72
|
+
kind: "email",
|
|
73
|
+
externalId: "alberto@example.com",
|
|
74
|
+
userId: user.id,
|
|
75
|
+
name: "Alberto",
|
|
76
|
+
email: "alberto@example.com",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("unknown: unregistered email returns the sentinel value", () => {
|
|
81
|
+
const resolution = resolveIdentityByEmail("nobody@example.com");
|
|
82
|
+
expect(resolution).toEqual({
|
|
83
|
+
status: "unknown",
|
|
84
|
+
kind: "email",
|
|
85
|
+
externalId: "nobody@example.com",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("renderIdentity", () => {
|
|
91
|
+
test("resolved renders 'Name (kind:externalId)'", () => {
|
|
92
|
+
const rendered = renderIdentity({
|
|
93
|
+
status: "resolved",
|
|
94
|
+
kind: "slack",
|
|
95
|
+
externalId: "U016H7XKZGS",
|
|
96
|
+
userId: "u1",
|
|
97
|
+
name: "Luis",
|
|
98
|
+
});
|
|
99
|
+
expect(rendered).toBe("Luis (slack:U016H7XKZGS)");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("unknown renders 'kind:externalId (unknown user)' — the sentinel never contains a name", () => {
|
|
103
|
+
const rendered = renderIdentity({
|
|
104
|
+
status: "unknown",
|
|
105
|
+
kind: "slack",
|
|
106
|
+
externalId: "U016H7XKZGS",
|
|
107
|
+
});
|
|
108
|
+
expect(rendered).toBe("slack:U016H7XKZGS (unknown user)");
|
|
109
|
+
expect(rendered).not.toContain("Luis");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the sentinel is deterministic for the same input", () => {
|
|
113
|
+
const resolution = resolveIdentity("github", "octocat-unlinked");
|
|
114
|
+
expect(renderIdentity(resolution)).toBe(renderIdentity(resolution));
|
|
115
|
+
expect(renderIdentity(resolution)).toBe("github:octocat-unlinked (unknown user)");
|
|
116
|
+
});
|
|
117
|
+
});
|