@desplega.ai/agent-swarm 1.57.4 → 1.58.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/openapi.json +1 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +20 -0
- package/src/be/db.ts +265 -2
- package/src/be/migrations/031_user_registry.sql +35 -0
- package/src/commands/runner.ts +8 -1
- package/src/github/handlers.ts +18 -1
- package/src/gitlab/handlers.ts +12 -1
- package/src/hooks/hook.ts +17 -2
- package/src/http/poll.ts +9 -0
- package/src/linear/sync.ts +25 -1
- package/src/server.ts +6 -0
- package/src/slack/actions.ts +10 -1
- package/src/slack/assistant.ts +7 -0
- package/src/slack/handlers.ts +9 -2
- package/src/tests/events-http.test.ts +2 -2
- package/src/tests/http-api-integration.test.ts +3 -3
- package/src/tests/linear-outbound-sync.test.ts +7 -7
- package/src/tests/preload.ts +14 -0
- package/src/tests/rest-api.test.ts +1 -1
- package/src/tests/user-identity.test.ts +306 -0
- package/src/tests/workflow-async-v2.test.ts +7 -7
- package/src/tests/workflow-engine-v2.test.ts +3 -3
- package/src/tests/workflow-hitl-routing.test.ts +7 -7
- package/src/tests/workflow-http-v2.test.ts +1 -1
- package/src/tests/workflow-retry-v2.test.ts +4 -4
- package/src/tests/workflow-retry-validation.test.ts +3 -3
- package/src/tests/workflow-validation-port-routing.test.ts +221 -0
- package/src/tools/get-task-details.ts +14 -1
- package/src/tools/manage-user.ts +172 -0
- package/src/tools/resolve-user.ts +55 -0
- package/src/tools/tool-config.ts +4 -0
- package/src/types.ts +26 -0
- package/src/workflows/engine.ts +18 -4
- package/src/workflows/executors/script.ts +16 -1
- package/src/workflows/validation.ts +6 -3
package/src/linear/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cancelTask, createTaskExtended, getAllAgents, getTaskById } from "../be/db";
|
|
1
|
+
import { cancelTask, createTaskExtended, getAllAgents, getTaskById, resolveUser } from "../be/db";
|
|
2
2
|
import { getOAuthTokens } from "../be/db-queries/oauth";
|
|
3
3
|
import {
|
|
4
4
|
createTrackerSync,
|
|
@@ -269,6 +269,17 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
269
269
|
return;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// Extract actor identity from Linear webhook payload
|
|
273
|
+
const actor = event.actor as Record<string, unknown> | undefined;
|
|
274
|
+
const actorLinearId = actor ? String(actor.id ?? "") : "";
|
|
275
|
+
const actorName = actor ? String(actor.name ?? "") : "";
|
|
276
|
+
const actorEmail = actor ? String(actor.email ?? "") : "";
|
|
277
|
+
const requestedByUserId = resolveUser({
|
|
278
|
+
linearUserId: actorLinearId || undefined,
|
|
279
|
+
email: actorEmail || undefined,
|
|
280
|
+
name: actorName || undefined,
|
|
281
|
+
})?.id;
|
|
282
|
+
|
|
272
283
|
// Check if we already track this issue
|
|
273
284
|
const existing = getTrackerSyncByExternalId("linear", "task", issueId);
|
|
274
285
|
const sessionId = agentSession ? String(agentSession.id ?? "") : "";
|
|
@@ -320,6 +331,7 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
320
331
|
agentId: lead?.id ?? "",
|
|
321
332
|
source: "linear",
|
|
322
333
|
taskType: "linear-issue",
|
|
334
|
+
requestedByUserId,
|
|
323
335
|
});
|
|
324
336
|
|
|
325
337
|
// Delete old tracker_sync before creating new one (UNIQUE constraint)
|
|
@@ -536,6 +548,17 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
|
|
|
536
548
|
// Task is completed/failed/cancelled or doesn't exist — create a new follow-up task
|
|
537
549
|
const lead = findLeadAgent();
|
|
538
550
|
|
|
551
|
+
// Extract actor identity from Linear webhook payload
|
|
552
|
+
const promptedActor = event.actor as Record<string, unknown> | undefined;
|
|
553
|
+
const promptedActorLinearId = promptedActor ? String(promptedActor.id ?? "") : "";
|
|
554
|
+
const promptedActorEmail = promptedActor ? String(promptedActor.email ?? "") : "";
|
|
555
|
+
const promptedActorName = promptedActor ? String(promptedActor.name ?? "") : "";
|
|
556
|
+
const promptedRequestedByUserId = resolveUser({
|
|
557
|
+
linearUserId: promptedActorLinearId || undefined,
|
|
558
|
+
email: promptedActorEmail || undefined,
|
|
559
|
+
name: promptedActorName || undefined,
|
|
560
|
+
})?.id;
|
|
561
|
+
|
|
539
562
|
const followupResult = resolveTemplate("linear.issue.followup", {
|
|
540
563
|
issue_identifier: issueIdentifier,
|
|
541
564
|
issue_title: issueTitle,
|
|
@@ -551,6 +574,7 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
|
|
|
551
574
|
agentId: lead?.id ?? "",
|
|
552
575
|
source: "linear",
|
|
553
576
|
taskType: "linear-issue",
|
|
577
|
+
requestedByUserId: promptedRequestedByUserId,
|
|
554
578
|
});
|
|
555
579
|
|
|
556
580
|
// Repoint the existing tracker_sync to the new follow-up task (can't create a
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerJoinSwarmTool } from "./tools/join-swarm";
|
|
|
15
15
|
// Messaging capability
|
|
16
16
|
import { registerListChannelsTool } from "./tools/list-channels";
|
|
17
17
|
import { registerListServicesTool } from "./tools/list-services";
|
|
18
|
+
import { registerManageUserTool } from "./tools/manage-user";
|
|
18
19
|
// MCP Servers capability
|
|
19
20
|
import {
|
|
20
21
|
registerMcpServerCreateTool,
|
|
@@ -44,6 +45,7 @@ import { registerRegisterAgentMailInboxTool } from "./tools/register-agentmail-i
|
|
|
44
45
|
// Services capability
|
|
45
46
|
import { registerRegisterServiceTool } from "./tools/register-service";
|
|
46
47
|
import { registerRequestHumanInputTool } from "./tools/request-human-input";
|
|
48
|
+
import { registerResolveUserTool } from "./tools/resolve-user";
|
|
47
49
|
// Scheduling capability
|
|
48
50
|
import {
|
|
49
51
|
registerCreateScheduleTool,
|
|
@@ -156,6 +158,10 @@ export function createServer() {
|
|
|
156
158
|
registerMyAgentInfoTool(server);
|
|
157
159
|
registerCancelTaskTool(server);
|
|
158
160
|
|
|
161
|
+
// User identity tools - always registered
|
|
162
|
+
registerResolveUserTool(server);
|
|
163
|
+
registerManageUserTool(server); // self-guards with lead check
|
|
164
|
+
|
|
159
165
|
// Debug tools - always registered (self-guards with lead check)
|
|
160
166
|
registerDbQueryTool(server);
|
|
161
167
|
|
package/src/slack/actions.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { App } from "@slack/bolt";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
cancelTask,
|
|
4
|
+
createTaskExtended,
|
|
5
|
+
getAgentById,
|
|
6
|
+
getLeadAgent,
|
|
7
|
+
getTaskById,
|
|
8
|
+
resolveUser,
|
|
9
|
+
} from "../be/db";
|
|
3
10
|
import { buildCancelledBlocks, getTaskLink } from "./blocks";
|
|
4
11
|
|
|
5
12
|
export function registerActionHandlers(app: App): void {
|
|
@@ -65,6 +72,7 @@ export function registerActionHandlers(app: App): void {
|
|
|
65
72
|
if (!originalTask || !originalTask.slackChannelId) return;
|
|
66
73
|
|
|
67
74
|
const lead = getLeadAgent();
|
|
75
|
+
const requestedByUserId = resolveUser({ slackUserId: body.user.id })?.id;
|
|
68
76
|
const followUpTask = createTaskExtended(followUpText, {
|
|
69
77
|
agentId: lead?.id,
|
|
70
78
|
source: "slack",
|
|
@@ -72,6 +80,7 @@ export function registerActionHandlers(app: App): void {
|
|
|
72
80
|
slackChannelId: originalTask.slackChannelId,
|
|
73
81
|
slackThreadTs: originalTask.slackThreadTs,
|
|
74
82
|
slackUserId: body.user.id,
|
|
83
|
+
requestedByUserId,
|
|
75
84
|
});
|
|
76
85
|
|
|
77
86
|
const taskLink = getTaskLink(followUpTask.id);
|
package/src/slack/assistant.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getAgentWorkingOnThread,
|
|
5
5
|
getLeadAgent,
|
|
6
6
|
getMostRecentTaskInThread,
|
|
7
|
+
resolveUser,
|
|
7
8
|
} from "../be/db";
|
|
8
9
|
import { resolveTemplate } from "../prompts/resolver";
|
|
9
10
|
import { bufferThreadMessage } from "./thread-buffer";
|
|
@@ -47,6 +48,9 @@ export function createAssistant(): Assistant {
|
|
|
47
48
|
const messageText = (msg.text as string) || "";
|
|
48
49
|
const userId = (msg.user as string) || "";
|
|
49
50
|
|
|
51
|
+
// Resolve canonical user identity (graceful — null if not found)
|
|
52
|
+
const requestedByUserId = userId ? resolveUser({ slackUserId: userId })?.id : undefined;
|
|
53
|
+
|
|
50
54
|
// 1. Check if an agent is already working in this thread
|
|
51
55
|
const workingAgent = getAgentWorkingOnThread(channelId, threadTs);
|
|
52
56
|
|
|
@@ -67,6 +71,7 @@ export function createAssistant(): Assistant {
|
|
|
67
71
|
slackThreadTs: threadTs,
|
|
68
72
|
slackUserId: userId,
|
|
69
73
|
parentTaskId: latestTask?.id,
|
|
74
|
+
requestedByUserId,
|
|
70
75
|
});
|
|
71
76
|
|
|
72
77
|
await setStatus("Processing follow-up...");
|
|
@@ -96,6 +101,7 @@ export function createAssistant(): Assistant {
|
|
|
96
101
|
slackChannelId: channelId,
|
|
97
102
|
slackThreadTs: threadTs,
|
|
98
103
|
slackUserId: userId,
|
|
104
|
+
requestedByUserId,
|
|
99
105
|
});
|
|
100
106
|
const offlineResult = resolveTemplate("slack.assistant.offline", {});
|
|
101
107
|
await say(offlineResult.text);
|
|
@@ -108,6 +114,7 @@ export function createAssistant(): Assistant {
|
|
|
108
114
|
slackChannelId: channelId,
|
|
109
115
|
slackThreadTs: threadTs,
|
|
110
116
|
slackUserId: userId,
|
|
117
|
+
requestedByUserId,
|
|
111
118
|
});
|
|
112
119
|
// setStatus shows typing indicator — watcher will post final result when done
|
|
113
120
|
} catch (error) {
|
package/src/slack/handlers.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { App } from "@slack/bolt";
|
|
2
2
|
import type { WebClient } from "@slack/web-api";
|
|
3
3
|
import {
|
|
4
|
-
createTask,
|
|
5
4
|
createTaskExtended,
|
|
6
5
|
getAgentById,
|
|
7
6
|
getAgentWorkingOnThread,
|
|
8
7
|
getLeadAgent,
|
|
9
8
|
getMostRecentTaskInThread,
|
|
10
9
|
getTasksByAgentId,
|
|
10
|
+
resolveUser,
|
|
11
11
|
} from "../be/db";
|
|
12
12
|
import { resolveTemplate } from "../prompts/resolver";
|
|
13
13
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
@@ -336,6 +336,9 @@ export function registerMessageHandler(app: App): void {
|
|
|
336
336
|
return;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
// Resolve canonical user identity (graceful — null if not found)
|
|
340
|
+
const requestedByUserId = resolveUser({ slackUserId: msg.user })?.id;
|
|
341
|
+
|
|
339
342
|
// Emit workflow trigger event for Slack messages
|
|
340
343
|
workflowEventBus.emit("slack.message", {
|
|
341
344
|
channel: msg.channel,
|
|
@@ -465,6 +468,7 @@ export function registerMessageHandler(app: App): void {
|
|
|
465
468
|
slackChannelId: msg.channel,
|
|
466
469
|
slackThreadTs: threadTs,
|
|
467
470
|
slackUserId: msg.user,
|
|
471
|
+
requestedByUserId,
|
|
468
472
|
});
|
|
469
473
|
|
|
470
474
|
await say({
|
|
@@ -537,17 +541,20 @@ export function registerMessageHandler(app: App): void {
|
|
|
537
541
|
slackThreadTs: threadTs,
|
|
538
542
|
slackUserId: msg.user,
|
|
539
543
|
parentTaskId: latestTask?.id,
|
|
544
|
+
requestedByUserId,
|
|
540
545
|
});
|
|
541
546
|
results.assigned.push({ agentName: agent.name, taskId: task.id });
|
|
542
547
|
continue;
|
|
543
548
|
}
|
|
544
549
|
|
|
545
550
|
// Workers receive tasks as before
|
|
546
|
-
const task =
|
|
551
|
+
const task = createTaskExtended(fullTaskDescription, {
|
|
552
|
+
agentId: agent.id,
|
|
547
553
|
source: "slack",
|
|
548
554
|
slackChannelId: msg.channel,
|
|
549
555
|
slackThreadTs: threadTs,
|
|
550
556
|
slackUserId: msg.user,
|
|
557
|
+
requestedByUserId,
|
|
551
558
|
});
|
|
552
559
|
|
|
553
560
|
// Check if agent has an in-progress task in this thread (queued follow-up)
|
|
@@ -38,7 +38,7 @@ async function waitForServer(url: string, timeoutMs = 15000) {
|
|
|
38
38
|
const r = await fetch(url);
|
|
39
39
|
if (r.ok) return;
|
|
40
40
|
} catch {}
|
|
41
|
-
await Bun.sleep(
|
|
41
|
+
await Bun.sleep(50);
|
|
42
42
|
}
|
|
43
43
|
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
|
44
44
|
}
|
|
@@ -74,7 +74,7 @@ afterAll(async () => {
|
|
|
74
74
|
await serverProc.exited;
|
|
75
75
|
} catch {}
|
|
76
76
|
}
|
|
77
|
-
await Bun.sleep(
|
|
77
|
+
await Bun.sleep(50);
|
|
78
78
|
try {
|
|
79
79
|
await unlink(TEST_DB_PATH);
|
|
80
80
|
await unlink(`${TEST_DB_PATH}-wal`);
|
|
@@ -64,7 +64,7 @@ async function waitForServer(url: string, timeoutMs = 15000) {
|
|
|
64
64
|
} catch {
|
|
65
65
|
// not ready yet
|
|
66
66
|
}
|
|
67
|
-
await Bun.sleep(
|
|
67
|
+
await Bun.sleep(50);
|
|
68
68
|
}
|
|
69
69
|
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
|
70
70
|
}
|
|
@@ -128,7 +128,7 @@ afterAll(async () => {
|
|
|
128
128
|
await serverProc.exited;
|
|
129
129
|
} catch {}
|
|
130
130
|
}
|
|
131
|
-
await Bun.sleep(
|
|
131
|
+
await Bun.sleep(50);
|
|
132
132
|
try {
|
|
133
133
|
await unlink(TEST_DB_PATH);
|
|
134
134
|
} catch {}
|
|
@@ -1557,7 +1557,7 @@ describe("AgentMail Webhooks (with filters)", () => {
|
|
|
1557
1557
|
await agentmailProc.exited;
|
|
1558
1558
|
} catch {}
|
|
1559
1559
|
}
|
|
1560
|
-
await Bun.sleep(
|
|
1560
|
+
await Bun.sleep(50);
|
|
1561
1561
|
try {
|
|
1562
1562
|
await unlink(AGENTMAIL_DB);
|
|
1563
1563
|
} catch {}
|
|
@@ -54,7 +54,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
// Allow async handler to run
|
|
57
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
58
58
|
|
|
59
59
|
expect(mockCreateComment).toHaveBeenCalledTimes(1);
|
|
60
60
|
const callArgs = mockCreateComment.mock.calls[0] as unknown[];
|
|
@@ -82,7 +82,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
82
82
|
failureReason: "Build error in module X",
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
86
86
|
|
|
87
87
|
expect(mockCreateComment).toHaveBeenCalledTimes(1);
|
|
88
88
|
const callArgs = mockCreateComment.mock.calls[0] as unknown[];
|
|
@@ -98,7 +98,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
98
98
|
output: "done",
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
102
102
|
|
|
103
103
|
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
104
104
|
});
|
|
@@ -123,7 +123,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
123
123
|
output: "done",
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
127
127
|
|
|
128
128
|
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
129
129
|
});
|
|
@@ -148,7 +148,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
148
148
|
output: "done",
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
152
152
|
|
|
153
153
|
expect(mockCreateComment).toHaveBeenCalledTimes(1);
|
|
154
154
|
});
|
|
@@ -172,7 +172,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
172
172
|
output: "done",
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
176
176
|
|
|
177
177
|
expect(mockCreateComment).toHaveBeenCalledTimes(1);
|
|
178
178
|
});
|
|
@@ -193,7 +193,7 @@ describe("Linear Outbound Sync", () => {
|
|
|
193
193
|
output: "done",
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
197
197
|
|
|
198
198
|
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
199
199
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { closeDb, getDb, initDb } from "../be/db";
|
|
2
|
+
|
|
3
|
+
// Prevent tests from making real network calls to LLM providers.
|
|
4
|
+
// The RawLlmExecutor tests already handle both success and failure paths,
|
|
5
|
+
// so removing the key just forces the fast failure path (~0ms vs ~2s of API calls).
|
|
6
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
7
|
+
|
|
8
|
+
// Build one fully-migrated AND fully-seeded SQLite template per worker.
|
|
9
|
+
// initDb runs all migrations, ensureAgentProfileColumns, seedContextVersions,
|
|
10
|
+
// seedDefaultTemplates, etc. We serialize the result so each test suite can
|
|
11
|
+
// restore from it instantly — no per-suite migration or seeding work at all.
|
|
12
|
+
initDb(":memory:");
|
|
13
|
+
(globalThis as any).__testMigrationTemplate = getDb().serialize();
|
|
14
|
+
closeDb();
|
|
@@ -599,7 +599,7 @@ describe("REST API Endpoints", () => {
|
|
|
599
599
|
test("should return empty stats for empty database", async () => {
|
|
600
600
|
// Clean up the database for this test
|
|
601
601
|
closeDb();
|
|
602
|
-
await unlink(TEST_DB_PATH);
|
|
602
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
603
603
|
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
604
604
|
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
605
605
|
initDb(TEST_DB_PATH);
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createTaskExtended,
|
|
7
|
+
createUser,
|
|
8
|
+
deleteUser,
|
|
9
|
+
getAllUsers,
|
|
10
|
+
getTaskById,
|
|
11
|
+
getUserById,
|
|
12
|
+
initDb,
|
|
13
|
+
resolveUser,
|
|
14
|
+
updateUser,
|
|
15
|
+
} from "../be/db";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = "./test-user-identity.sqlite";
|
|
18
|
+
|
|
19
|
+
let leadAgent: ReturnType<typeof createAgent>;
|
|
20
|
+
let workerAgent: ReturnType<typeof createAgent>;
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
initDb(TEST_DB_PATH);
|
|
24
|
+
leadAgent = createAgent({ name: "TestLead", isLead: true, status: "idle" });
|
|
25
|
+
workerAgent = createAgent({ name: "TestWorker", isLead: false, status: "idle" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
closeDb();
|
|
30
|
+
try {
|
|
31
|
+
unlinkSync(TEST_DB_PATH);
|
|
32
|
+
unlinkSync(`${TEST_DB_PATH}-wal`);
|
|
33
|
+
unlinkSync(`${TEST_DB_PATH}-shm`);
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─── User CRUD ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("createUser", () => {
|
|
42
|
+
test("creates a user with required fields only", () => {
|
|
43
|
+
const user = createUser({ name: "Alice" });
|
|
44
|
+
expect(user.id).toBeDefined();
|
|
45
|
+
expect(user.name).toBe("Alice");
|
|
46
|
+
expect(user.email).toBeUndefined();
|
|
47
|
+
expect(user.role).toBeUndefined();
|
|
48
|
+
expect(user.emailAliases).toEqual([]);
|
|
49
|
+
expect(user.preferredChannel).toBe("slack");
|
|
50
|
+
expect(user.createdAt).toBeDefined();
|
|
51
|
+
expect(user.lastUpdatedAt).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("creates a user with all fields", () => {
|
|
55
|
+
const user = createUser({
|
|
56
|
+
name: "Bob",
|
|
57
|
+
email: "bob@example.com",
|
|
58
|
+
role: "engineer",
|
|
59
|
+
notes: "Test user",
|
|
60
|
+
slackUserId: "U_BOB",
|
|
61
|
+
linearUserId: "lin-bob-uuid",
|
|
62
|
+
githubUsername: "bob-gh",
|
|
63
|
+
gitlabUsername: "bob-gl",
|
|
64
|
+
emailAliases: ["bob2@example.com", "robert@example.com"],
|
|
65
|
+
preferredChannel: "email",
|
|
66
|
+
timezone: "America/New_York",
|
|
67
|
+
});
|
|
68
|
+
expect(user.name).toBe("Bob");
|
|
69
|
+
expect(user.email).toBe("bob@example.com");
|
|
70
|
+
expect(user.role).toBe("engineer");
|
|
71
|
+
expect(user.notes).toBe("Test user");
|
|
72
|
+
expect(user.slackUserId).toBe("U_BOB");
|
|
73
|
+
expect(user.linearUserId).toBe("lin-bob-uuid");
|
|
74
|
+
expect(user.githubUsername).toBe("bob-gh");
|
|
75
|
+
expect(user.gitlabUsername).toBe("bob-gl");
|
|
76
|
+
expect(user.emailAliases).toEqual(["bob2@example.com", "robert@example.com"]);
|
|
77
|
+
expect(user.preferredChannel).toBe("email");
|
|
78
|
+
expect(user.timezone).toBe("America/New_York");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("rejects duplicate slackUserId", () => {
|
|
82
|
+
createUser({ name: "First", slackUserId: "U_UNIQUE" });
|
|
83
|
+
expect(() => createUser({ name: "Second", slackUserId: "U_UNIQUE" })).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects duplicate githubUsername", () => {
|
|
87
|
+
createUser({ name: "GH1", githubUsername: "unique-gh" });
|
|
88
|
+
expect(() => createUser({ name: "GH2", githubUsername: "unique-gh" })).toThrow();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getUserById", () => {
|
|
93
|
+
test("returns user by ID", () => {
|
|
94
|
+
const created = createUser({ name: "GetById" });
|
|
95
|
+
const fetched = getUserById(created.id);
|
|
96
|
+
expect(fetched).toBeDefined();
|
|
97
|
+
expect(fetched!.name).toBe("GetById");
|
|
98
|
+
expect(fetched!.id).toBe(created.id);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns null for non-existent ID", () => {
|
|
102
|
+
expect(getUserById("nonexistent")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("getAllUsers", () => {
|
|
107
|
+
test("returns all users", () => {
|
|
108
|
+
const users = getAllUsers();
|
|
109
|
+
expect(users.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("updateUser", () => {
|
|
114
|
+
test("updates specific fields", () => {
|
|
115
|
+
const user = createUser({ name: "UpdateMe", role: "intern" });
|
|
116
|
+
const updated = updateUser(user.id, { role: "senior", email: "updated@test.com" });
|
|
117
|
+
expect(updated).toBeDefined();
|
|
118
|
+
expect(updated!.role).toBe("senior");
|
|
119
|
+
expect(updated!.email).toBe("updated@test.com");
|
|
120
|
+
expect(updated!.name).toBe("UpdateMe"); // unchanged
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("updates emailAliases", () => {
|
|
124
|
+
const user = createUser({ name: "AliasUser" });
|
|
125
|
+
const updated = updateUser(user.id, { emailAliases: ["alias1@test.com", "alias2@test.com"] });
|
|
126
|
+
expect(updated!.emailAliases).toEqual(["alias1@test.com", "alias2@test.com"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("returns null for non-existent user", () => {
|
|
130
|
+
expect(updateUser("nonexistent", { name: "Nope" })).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("returns unchanged user when no updates provided", () => {
|
|
134
|
+
const user = createUser({ name: "NoChange" });
|
|
135
|
+
const result = updateUser(user.id, {});
|
|
136
|
+
expect(result).toBeDefined();
|
|
137
|
+
expect(result!.name).toBe("NoChange");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("deleteUser", () => {
|
|
142
|
+
test("deletes existing user", () => {
|
|
143
|
+
const user = createUser({ name: "DeleteMe" });
|
|
144
|
+
expect(deleteUser(user.id)).toBe(true);
|
|
145
|
+
expect(getUserById(user.id)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("returns false for non-existent user", () => {
|
|
149
|
+
expect(deleteUser("nonexistent")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("clears requestedByUserId on tasks when user is deleted", () => {
|
|
153
|
+
const user = createUser({ name: "TaskOwner", slackUserId: "U_TASKOWNER" });
|
|
154
|
+
const task = createTaskExtended("test task with user", {
|
|
155
|
+
agentId: workerAgent.id,
|
|
156
|
+
source: "slack",
|
|
157
|
+
requestedByUserId: user.id,
|
|
158
|
+
});
|
|
159
|
+
expect(getTaskById(task.id)!.requestedByUserId).toBe(user.id);
|
|
160
|
+
|
|
161
|
+
deleteUser(user.id);
|
|
162
|
+
expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ─── resolveUser ──────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("resolveUser", () => {
|
|
169
|
+
let testUser: ReturnType<typeof createUser>;
|
|
170
|
+
|
|
171
|
+
beforeAll(() => {
|
|
172
|
+
testUser = createUser({
|
|
173
|
+
name: "Resolve TestUser",
|
|
174
|
+
email: "resolve-test@example.com",
|
|
175
|
+
slackUserId: "U_RESOLVE_SLACK",
|
|
176
|
+
linearUserId: "lin-resolve-uuid",
|
|
177
|
+
githubUsername: "resolve-gh",
|
|
178
|
+
gitlabUsername: "resolve-gl",
|
|
179
|
+
emailAliases: ["resolve-alias@example.com"],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("resolves by slackUserId", () => {
|
|
184
|
+
const user = resolveUser({ slackUserId: "U_RESOLVE_SLACK" });
|
|
185
|
+
expect(user).toBeDefined();
|
|
186
|
+
expect(user!.id).toBe(testUser.id);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("resolves by linearUserId", () => {
|
|
190
|
+
const user = resolveUser({ linearUserId: "lin-resolve-uuid" });
|
|
191
|
+
expect(user).toBeDefined();
|
|
192
|
+
expect(user!.id).toBe(testUser.id);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("resolves by githubUsername", () => {
|
|
196
|
+
const user = resolveUser({ githubUsername: "resolve-gh" });
|
|
197
|
+
expect(user).toBeDefined();
|
|
198
|
+
expect(user!.id).toBe(testUser.id);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("resolves by gitlabUsername", () => {
|
|
202
|
+
const user = resolveUser({ gitlabUsername: "resolve-gl" });
|
|
203
|
+
expect(user).toBeDefined();
|
|
204
|
+
expect(user!.id).toBe(testUser.id);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("resolves by primary email", () => {
|
|
208
|
+
const user = resolveUser({ email: "resolve-test@example.com" });
|
|
209
|
+
expect(user).toBeDefined();
|
|
210
|
+
expect(user!.id).toBe(testUser.id);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("resolves by email alias (case-insensitive)", () => {
|
|
214
|
+
const user = resolveUser({ email: "RESOLVE-ALIAS@EXAMPLE.COM" });
|
|
215
|
+
expect(user).toBeDefined();
|
|
216
|
+
expect(user!.id).toBe(testUser.id);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("resolves by name substring (case-insensitive)", () => {
|
|
220
|
+
const user = resolveUser({ name: "resolve testuser" });
|
|
221
|
+
expect(user).toBeDefined();
|
|
222
|
+
expect(user!.id).toBe(testUser.id);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("returns null for no match", () => {
|
|
226
|
+
expect(resolveUser({ slackUserId: "U_NONEXISTENT" })).toBeNull();
|
|
227
|
+
expect(resolveUser({ email: "nobody@nowhere.com" })).toBeNull();
|
|
228
|
+
expect(resolveUser({ name: "ZZZNoSuchPerson" })).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("prioritizes platform ID over email", () => {
|
|
232
|
+
// Create a second user with different slack ID
|
|
233
|
+
const user2 = createUser({
|
|
234
|
+
name: "PriorityUser",
|
|
235
|
+
slackUserId: "U_OTHER_PRIORITY",
|
|
236
|
+
});
|
|
237
|
+
// When resolving with both slackUserId and email, slackUserId wins
|
|
238
|
+
const resolved = resolveUser({
|
|
239
|
+
slackUserId: "U_OTHER_PRIORITY",
|
|
240
|
+
email: "resolve-test@example.com", // belongs to testUser
|
|
241
|
+
});
|
|
242
|
+
expect(resolved!.id).toBe(user2.id);
|
|
243
|
+
// Cleanup
|
|
244
|
+
deleteUser(user2.id);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ─── requestedByUserId on tasks ─────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("requestedByUserId in tasks", () => {
|
|
251
|
+
test("createTaskExtended stores requestedByUserId", () => {
|
|
252
|
+
const user = createUser({ name: "Requester" });
|
|
253
|
+
const task = createTaskExtended("task with requester", {
|
|
254
|
+
agentId: workerAgent.id,
|
|
255
|
+
source: "slack",
|
|
256
|
+
requestedByUserId: user.id,
|
|
257
|
+
});
|
|
258
|
+
const fetched = getTaskById(task.id);
|
|
259
|
+
expect(fetched!.requestedByUserId).toBe(user.id);
|
|
260
|
+
deleteUser(user.id);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("requestedByUserId inherits from parent task", () => {
|
|
264
|
+
const user = createUser({ name: "ParentRequester" });
|
|
265
|
+
const parent = createTaskExtended("parent task", {
|
|
266
|
+
agentId: leadAgent.id,
|
|
267
|
+
source: "slack",
|
|
268
|
+
requestedByUserId: user.id,
|
|
269
|
+
});
|
|
270
|
+
const child = createTaskExtended("child task", {
|
|
271
|
+
agentId: workerAgent.id,
|
|
272
|
+
source: "mcp",
|
|
273
|
+
parentTaskId: parent.id,
|
|
274
|
+
});
|
|
275
|
+
const fetchedChild = getTaskById(child.id);
|
|
276
|
+
expect(fetchedChild!.requestedByUserId).toBe(user.id);
|
|
277
|
+
deleteUser(user.id);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("explicit requestedByUserId overrides parent inheritance", () => {
|
|
281
|
+
const user1 = createUser({ name: "User1" });
|
|
282
|
+
const user2 = createUser({ name: "User2" });
|
|
283
|
+
const parent = createTaskExtended("parent", {
|
|
284
|
+
agentId: leadAgent.id,
|
|
285
|
+
source: "slack",
|
|
286
|
+
requestedByUserId: user1.id,
|
|
287
|
+
});
|
|
288
|
+
const child = createTaskExtended("child", {
|
|
289
|
+
agentId: workerAgent.id,
|
|
290
|
+
source: "mcp",
|
|
291
|
+
parentTaskId: parent.id,
|
|
292
|
+
requestedByUserId: user2.id,
|
|
293
|
+
});
|
|
294
|
+
expect(getTaskById(child.id)!.requestedByUserId).toBe(user2.id);
|
|
295
|
+
deleteUser(user1.id);
|
|
296
|
+
deleteUser(user2.id);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("task without requestedByUserId has undefined", () => {
|
|
300
|
+
const task = createTaskExtended("no user task", {
|
|
301
|
+
agentId: workerAgent.id,
|
|
302
|
+
source: "mcp",
|
|
303
|
+
});
|
|
304
|
+
expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
});
|