@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
|
@@ -215,7 +215,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
// Give the async handler time to process
|
|
218
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
219
219
|
|
|
220
220
|
// Workflow should be completed now
|
|
221
221
|
const updatedRun = getWorkflowRun(runId);
|
|
@@ -260,7 +260,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
260
260
|
workflowRunStepId: taskStep.id,
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
264
264
|
|
|
265
265
|
const updatedRun = getWorkflowRun(runId);
|
|
266
266
|
expect(updatedRun!.status).toBe("failed");
|
|
@@ -291,7 +291,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
291
291
|
workflowRunStepId: taskStep.id,
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
295
295
|
|
|
296
296
|
const updatedRun = getWorkflowRun(runId);
|
|
297
297
|
expect(updatedRun!.status).toBe("failed");
|
|
@@ -455,7 +455,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
455
455
|
workflowRunId: runId,
|
|
456
456
|
workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-a")!.id,
|
|
457
457
|
});
|
|
458
|
-
await new Promise((r) => setTimeout(r,
|
|
458
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
459
459
|
|
|
460
460
|
// After A completes — merge should NOT have been created yet (B, C still pending)
|
|
461
461
|
steps = getWorkflowRunStepsByRunId(runId);
|
|
@@ -472,7 +472,7 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
472
472
|
workflowRunId: runId,
|
|
473
473
|
workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-b")!.id,
|
|
474
474
|
});
|
|
475
|
-
await new Promise((r) => setTimeout(r,
|
|
475
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
476
476
|
|
|
477
477
|
// After B completes — merge STILL should not exist (C still pending)
|
|
478
478
|
steps = getWorkflowRunStepsByRunId(runId);
|
|
@@ -489,11 +489,11 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
489
489
|
workflowRunId: runId,
|
|
490
490
|
workflowRunStepId: reviewSteps.find((s) => s.nodeId === "review-c")!.id,
|
|
491
491
|
});
|
|
492
|
-
await new Promise((r) => setTimeout(r,
|
|
492
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
493
493
|
|
|
494
494
|
// Now ALL 3 are done — merge should execute exactly ONCE
|
|
495
495
|
// Allow extra time for serialized queue processing
|
|
496
|
-
await new Promise((r) => setTimeout(r,
|
|
496
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
497
497
|
|
|
498
498
|
steps = getWorkflowRunStepsByRunId(runId);
|
|
499
499
|
const mergeSteps = steps.filter((s) => s.nodeId === "merge");
|
|
@@ -386,8 +386,8 @@ describe("Workflow Engine v2 (Phase 3)", () => {
|
|
|
386
386
|
const registry = createTestRegistry();
|
|
387
387
|
const def: WorkflowDefinition = {
|
|
388
388
|
nodes: [
|
|
389
|
-
//
|
|
390
|
-
{ id: "slow", type: "slow", config: { delayMs:
|
|
389
|
+
// 2s delay should exceed the 500ms timeout
|
|
390
|
+
{ id: "slow", type: "slow", config: { delayMs: 2_000, timeoutMs: 500 } },
|
|
391
391
|
],
|
|
392
392
|
};
|
|
393
393
|
|
|
@@ -397,7 +397,7 @@ describe("Workflow Engine v2 (Phase 3)", () => {
|
|
|
397
397
|
const run = getWorkflowRun(runId);
|
|
398
398
|
expect(run!.status).toBe("failed");
|
|
399
399
|
expect(run!.error).toContain("timed out");
|
|
400
|
-
},
|
|
400
|
+
}, 5_000); // Allow test up to 5s
|
|
401
401
|
});
|
|
402
402
|
|
|
403
403
|
// ─── Validation ───────────────────────────────────────────
|
|
@@ -191,7 +191,7 @@ async function runHITLWorkflow(
|
|
|
191
191
|
// Use a small delay to let the event handler finish
|
|
192
192
|
const resumePromise = new Promise<void>((resolve) => {
|
|
193
193
|
// Give the event handler time to run walkGraph
|
|
194
|
-
setTimeout(resolve,
|
|
194
|
+
setTimeout(resolve, 10);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
eventBus.emit("approval.resolved", {
|
|
@@ -360,7 +360,7 @@ describe("HITL port-based routing", () => {
|
|
|
360
360
|
const requestId1 = hitlExecutor.lastRequestId!;
|
|
361
361
|
|
|
362
362
|
// REJECT — should loop back to generate-question
|
|
363
|
-
const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
363
|
+
const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
364
364
|
eventBus.emit("approval.resolved", {
|
|
365
365
|
requestId: requestId1,
|
|
366
366
|
status: "rejected",
|
|
@@ -392,7 +392,7 @@ describe("HITL port-based routing", () => {
|
|
|
392
392
|
expect(requestId2).not.toBe(requestId1); // Different request
|
|
393
393
|
|
|
394
394
|
// APPROVE — should go to success
|
|
395
|
-
const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
395
|
+
const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
396
396
|
eventBus.emit("approval.resolved", {
|
|
397
397
|
requestId: requestId2,
|
|
398
398
|
status: "approved",
|
|
@@ -458,7 +458,7 @@ describe("HITL port-based routing", () => {
|
|
|
458
458
|
|
|
459
459
|
// Simulate async task completion for generate-question (1st iteration)
|
|
460
460
|
const genMeta1 = asyncTaskExecutor.lastMeta!;
|
|
461
|
-
const taskComplete1Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
461
|
+
const taskComplete1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
462
462
|
eventBus.emit("task.completed", {
|
|
463
463
|
taskId: "fake-task-1",
|
|
464
464
|
output: JSON.stringify({ question: "What is 2+2?" }),
|
|
@@ -478,7 +478,7 @@ describe("HITL port-based routing", () => {
|
|
|
478
478
|
const requestId1 = hitlExecutor.lastRequestId!;
|
|
479
479
|
|
|
480
480
|
// REJECT — should loop back to generate-question (async)
|
|
481
|
-
const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
481
|
+
const reject1Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
482
482
|
eventBus.emit("approval.resolved", {
|
|
483
483
|
requestId: requestId1,
|
|
484
484
|
status: "rejected",
|
|
@@ -496,7 +496,7 @@ describe("HITL port-based routing", () => {
|
|
|
496
496
|
expect(genMeta2.stepId).not.toBe(genMeta1.stepId); // Different step
|
|
497
497
|
|
|
498
498
|
// Simulate async task completion for generate-question (2nd iteration)
|
|
499
|
-
const taskComplete2Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
499
|
+
const taskComplete2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
500
500
|
eventBus.emit("task.completed", {
|
|
501
501
|
taskId: "fake-task-2",
|
|
502
502
|
output: JSON.stringify({ question: "What is 3+3?" }),
|
|
@@ -523,7 +523,7 @@ describe("HITL port-based routing", () => {
|
|
|
523
523
|
expect(requestId2).not.toBe(requestId1);
|
|
524
524
|
|
|
525
525
|
// APPROVE — should go to success
|
|
526
|
-
const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve,
|
|
526
|
+
const approve2Promise = new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
527
527
|
eventBus.emit("approval.resolved", {
|
|
528
528
|
requestId: requestId2,
|
|
529
529
|
status: "approved",
|
|
@@ -652,7 +652,7 @@ describe("Workflow HTTP API v2", () => {
|
|
|
652
652
|
const { runId } = (await triggerRes.json()) as { runId: string };
|
|
653
653
|
|
|
654
654
|
// Wait for run to complete
|
|
655
|
-
await new Promise((r) => setTimeout(r,
|
|
655
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
656
656
|
|
|
657
657
|
// Try to cancel — should fail
|
|
658
658
|
const cancelRes = await fetch(`${baseUrl}/api/workflow-runs/${runId}/cancel`, {
|
|
@@ -251,7 +251,7 @@ describe("Workflow Retry v2 (Phase 4)", () => {
|
|
|
251
251
|
expect(step.retryCount).toBe(1);
|
|
252
252
|
|
|
253
253
|
// Wait for nextRetryAt to pass
|
|
254
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
255
255
|
|
|
256
256
|
// Verify getRetryableSteps finds it
|
|
257
257
|
const retryable = getRetryableSteps();
|
|
@@ -294,11 +294,11 @@ describe("Workflow Retry v2 (Phase 4)", () => {
|
|
|
294
294
|
|
|
295
295
|
if (step.status === "failed" && step.nextRetryAt) {
|
|
296
296
|
// Was given one retry chance — let it expire and start poller
|
|
297
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
298
298
|
|
|
299
299
|
// Start poller with very short interval
|
|
300
|
-
startRetryPoller(registry,
|
|
301
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
300
|
+
startRetryPoller(registry, 10);
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
302
302
|
stopRetryPoller();
|
|
303
303
|
|
|
304
304
|
// After retry, the second failure should be terminal (retryCount >= maxRetries)
|
|
@@ -192,10 +192,10 @@ describe("Retry Poller — Validation on Retry", () => {
|
|
|
192
192
|
const validationCountAfterInitial = validateCallCount;
|
|
193
193
|
|
|
194
194
|
// Wait for nextRetryAt to pass, then run the poller
|
|
195
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
196
196
|
|
|
197
|
-
startRetryPoller(registry,
|
|
198
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
197
|
+
startRetryPoller(registry, 10);
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
199
199
|
stopRetryPoller();
|
|
200
200
|
|
|
201
201
|
// Validation should have been called again during the retry
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createWorkflow,
|
|
7
|
+
getWorkflowRun,
|
|
8
|
+
getWorkflowRunStepsByRunId,
|
|
9
|
+
initDb,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
12
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
13
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
14
|
+
import {
|
|
15
|
+
BaseExecutor,
|
|
16
|
+
type ExecutorDependencies,
|
|
17
|
+
type ExecutorResult,
|
|
18
|
+
} from "../workflows/executors/base";
|
|
19
|
+
import { PropertyMatchExecutor } from "../workflows/executors/property-match";
|
|
20
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
21
|
+
import { interpolate } from "../workflows/template";
|
|
22
|
+
|
|
23
|
+
const TEST_DB_PATH = "./test-workflow-validation-port-routing.sqlite";
|
|
24
|
+
|
|
25
|
+
// ─── Test Executors ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Executor that returns a configurable object as output.
|
|
29
|
+
* Used to produce output that property-match can validate.
|
|
30
|
+
*/
|
|
31
|
+
class ObjectOutputExecutor extends BaseExecutor<
|
|
32
|
+
typeof ObjectOutputExecutor.schema,
|
|
33
|
+
typeof ObjectOutputExecutor.outSchema
|
|
34
|
+
> {
|
|
35
|
+
static readonly schema = z.object({ approved: z.boolean() });
|
|
36
|
+
static readonly outSchema = z.object({ approved: z.boolean() });
|
|
37
|
+
|
|
38
|
+
readonly type = "object-output";
|
|
39
|
+
readonly mode = "instant" as const;
|
|
40
|
+
readonly configSchema = ObjectOutputExecutor.schema;
|
|
41
|
+
readonly outputSchema = ObjectOutputExecutor.outSchema;
|
|
42
|
+
|
|
43
|
+
protected async execute(
|
|
44
|
+
config: z.infer<typeof ObjectOutputExecutor.schema>,
|
|
45
|
+
): Promise<ExecutorResult<z.infer<typeof ObjectOutputExecutor.outSchema>>> {
|
|
46
|
+
return { status: "success", output: { approved: config.approved } };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Terminal executor that just succeeds. Used for leaf nodes.
|
|
52
|
+
*/
|
|
53
|
+
class NoopExecutor extends BaseExecutor<typeof NoopExecutor.schema, typeof NoopExecutor.outSchema> {
|
|
54
|
+
static readonly schema = z.object({});
|
|
55
|
+
static readonly outSchema = z.object({ done: z.boolean() });
|
|
56
|
+
|
|
57
|
+
readonly type = "noop";
|
|
58
|
+
readonly mode = "instant" as const;
|
|
59
|
+
readonly configSchema = NoopExecutor.schema;
|
|
60
|
+
readonly outputSchema = NoopExecutor.outSchema;
|
|
61
|
+
|
|
62
|
+
protected async execute(): Promise<ExecutorResult<z.infer<typeof NoopExecutor.outSchema>>> {
|
|
63
|
+
return { status: "success", output: { done: true } };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Mock Dependencies ───────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
import * as db from "../be/db";
|
|
70
|
+
|
|
71
|
+
const mockDeps: ExecutorDependencies = {
|
|
72
|
+
db: db as typeof import("../be/db"),
|
|
73
|
+
eventBus: workflowEventBus,
|
|
74
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function createTestRegistry(): ExecutorRegistry {
|
|
78
|
+
const registry = new ExecutorRegistry();
|
|
79
|
+
registry.register(new ObjectOutputExecutor(mockDeps));
|
|
80
|
+
registry.register(new NoopExecutor(mockDeps));
|
|
81
|
+
registry.register(new PropertyMatchExecutor(mockDeps));
|
|
82
|
+
return registry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let workflowCounter = 0;
|
|
86
|
+
|
|
87
|
+
function makeWorkflow(def: WorkflowDefinition): Workflow {
|
|
88
|
+
workflowCounter++;
|
|
89
|
+
return createWorkflow({
|
|
90
|
+
name: `test-val-port-routing-${workflowCounter}-${Date.now()}`,
|
|
91
|
+
definition: def,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Setup / Teardown ────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
let registry: ExecutorRegistry;
|
|
98
|
+
|
|
99
|
+
beforeAll(async () => {
|
|
100
|
+
try {
|
|
101
|
+
await unlink(TEST_DB_PATH);
|
|
102
|
+
} catch {}
|
|
103
|
+
initDb(TEST_DB_PATH);
|
|
104
|
+
registry = createTestRegistry();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
closeDb();
|
|
109
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
110
|
+
try {
|
|
111
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── Tests ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("Validation Port Routing", () => {
|
|
119
|
+
/**
|
|
120
|
+
* Regression test for: when a node has record-based `next` (pass/fail ports)
|
|
121
|
+
* and validation with `mustPass: false`, only the matching port should be activated.
|
|
122
|
+
* Previously, all ports were activated because `result.nextPort` was never set.
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
function makePortRoutingWorkflow(approved: boolean): WorkflowDefinition {
|
|
126
|
+
return {
|
|
127
|
+
nodes: [
|
|
128
|
+
{
|
|
129
|
+
id: "check",
|
|
130
|
+
type: "object-output",
|
|
131
|
+
config: { approved },
|
|
132
|
+
next: { pass: "on-pass", fail: "on-fail" },
|
|
133
|
+
validation: {
|
|
134
|
+
executor: "property-match",
|
|
135
|
+
config: {
|
|
136
|
+
conditions: [{ field: "check.approved", op: "eq", value: true }],
|
|
137
|
+
},
|
|
138
|
+
mustPass: false,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "on-pass",
|
|
143
|
+
type: "noop",
|
|
144
|
+
config: {},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "on-fail",
|
|
148
|
+
type: "noop",
|
|
149
|
+
config: {},
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("validation passes → only 'pass' port successor is created", async () => {
|
|
156
|
+
const workflow = makeWorkflow(makePortRoutingWorkflow(true));
|
|
157
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
158
|
+
|
|
159
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
160
|
+
const nodeIds = steps.map((s) => s.nodeId);
|
|
161
|
+
|
|
162
|
+
// "check" should be completed, "on-pass" should exist, "on-fail" should NOT
|
|
163
|
+
expect(nodeIds).toContain("check");
|
|
164
|
+
expect(nodeIds).toContain("on-pass");
|
|
165
|
+
expect(nodeIds).not.toContain("on-fail");
|
|
166
|
+
|
|
167
|
+
const run = getWorkflowRun(runId);
|
|
168
|
+
expect(run!.status).toBe("completed");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("validation fails (mustPass: false) → only 'fail' port successor is created", async () => {
|
|
172
|
+
const workflow = makeWorkflow(makePortRoutingWorkflow(false));
|
|
173
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
174
|
+
|
|
175
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
176
|
+
const nodeIds = steps.map((s) => s.nodeId);
|
|
177
|
+
|
|
178
|
+
// "check" should be completed, "on-fail" should exist, "on-pass" should NOT
|
|
179
|
+
expect(nodeIds).toContain("check");
|
|
180
|
+
expect(nodeIds).toContain("on-fail");
|
|
181
|
+
expect(nodeIds).not.toContain("on-pass");
|
|
182
|
+
|
|
183
|
+
const run = getWorkflowRun(runId);
|
|
184
|
+
expect(run!.status).toBe("completed");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("string-based next is unaffected by validation port routing", async () => {
|
|
188
|
+
const workflow = makeWorkflow({
|
|
189
|
+
nodes: [
|
|
190
|
+
{
|
|
191
|
+
id: "check",
|
|
192
|
+
type: "object-output",
|
|
193
|
+
config: { approved: false },
|
|
194
|
+
next: "successor",
|
|
195
|
+
validation: {
|
|
196
|
+
executor: "property-match",
|
|
197
|
+
config: {
|
|
198
|
+
conditions: [{ field: "check.approved", op: "eq", value: true }],
|
|
199
|
+
},
|
|
200
|
+
mustPass: false,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "successor",
|
|
205
|
+
type: "noop",
|
|
206
|
+
config: {},
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
212
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
213
|
+
const nodeIds = steps.map((s) => s.nodeId);
|
|
214
|
+
|
|
215
|
+
expect(nodeIds).toContain("check");
|
|
216
|
+
expect(nodeIds).toContain("successor");
|
|
217
|
+
|
|
218
|
+
const run = getWorkflowRun(runId);
|
|
219
|
+
expect(run!.status).toBe("completed");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import { getLogsByTaskIdChronological, getTaskById } from "@/be/db";
|
|
3
|
+
import { getLogsByTaskIdChronological, getTaskById, getUserById } from "@/be/db";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
5
|
import { AgentLogSchema, AgentTaskSchema } from "@/types";
|
|
6
6
|
|
|
@@ -21,6 +21,10 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
|
|
|
21
21
|
success: z.boolean(),
|
|
22
22
|
message: z.string(),
|
|
23
23
|
task: AgentTaskSchema.optional(),
|
|
24
|
+
requestedBy: z
|
|
25
|
+
.object({ name: z.string(), email: z.string().optional() })
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Resolved user who requested this task"),
|
|
24
28
|
logs: z.array(AgentLogSchema).optional(),
|
|
25
29
|
}),
|
|
26
30
|
},
|
|
@@ -40,6 +44,14 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
|
|
|
40
44
|
|
|
41
45
|
const logs = getLogsByTaskIdChronological(taskId);
|
|
42
46
|
|
|
47
|
+
// Resolve requesting user details if available
|
|
48
|
+
const requestedByUser = task.requestedByUserId
|
|
49
|
+
? getUserById(task.requestedByUserId)
|
|
50
|
+
: undefined;
|
|
51
|
+
const requestedBy = requestedByUser
|
|
52
|
+
? { name: requestedByUser.name, email: requestedByUser.email }
|
|
53
|
+
: undefined;
|
|
54
|
+
|
|
43
55
|
return {
|
|
44
56
|
content: [{ type: "text", text: `Task "${taskId}" details retrieved.` }],
|
|
45
57
|
structuredContent: {
|
|
@@ -47,6 +59,7 @@ export const registerGetTaskDetailsTool = (server: McpServer) => {
|
|
|
47
59
|
success: true,
|
|
48
60
|
message: `Task "${taskId}" details retrieved.`,
|
|
49
61
|
task,
|
|
62
|
+
requestedBy,
|
|
50
63
|
logs,
|
|
51
64
|
},
|
|
52
65
|
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createUser,
|
|
5
|
+
deleteUser,
|
|
6
|
+
getAgentById,
|
|
7
|
+
getAllUsers,
|
|
8
|
+
getUserById,
|
|
9
|
+
updateUser,
|
|
10
|
+
} from "@/be/db";
|
|
11
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
12
|
+
|
|
13
|
+
export const registerManageUserTool = (server: McpServer) => {
|
|
14
|
+
createToolRegistrar(server)(
|
|
15
|
+
"manage-user",
|
|
16
|
+
{
|
|
17
|
+
title: "Manage user profiles",
|
|
18
|
+
description: "Create, update, delete, or list user profiles in the user registry. Lead-only.",
|
|
19
|
+
annotations: { readOnlyHint: false },
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
action: z.enum(["create", "update", "delete", "list", "get"]).describe("Action to perform"),
|
|
22
|
+
userId: z.string().optional().describe("User ID (required for update/delete/get)"),
|
|
23
|
+
name: z.string().optional().describe("Display name (required for create)"),
|
|
24
|
+
email: z.string().optional().describe("Primary email address"),
|
|
25
|
+
role: z.string().optional().describe('Role (e.g., "founder", "engineer")'),
|
|
26
|
+
notes: z.string().optional().describe("Free-form notes"),
|
|
27
|
+
slackUserId: z.string().optional().describe("Slack user ID"),
|
|
28
|
+
linearUserId: z.string().optional().describe("Linear user UUID"),
|
|
29
|
+
githubUsername: z.string().optional().describe("GitHub username"),
|
|
30
|
+
gitlabUsername: z.string().optional().describe("GitLab username"),
|
|
31
|
+
emailAliases: z.array(z.string()).optional().describe("Additional email addresses"),
|
|
32
|
+
preferredChannel: z.string().optional().describe("Preferred contact channel"),
|
|
33
|
+
timezone: z.string().optional().describe("Timezone (e.g., America/New_York)"),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
async (input, requestInfo) => {
|
|
37
|
+
const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
|
|
38
|
+
if (!callerAgent?.isLead) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{ type: "text" as const, text: "Only the lead agent can manage user profiles." },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (input.action) {
|
|
47
|
+
case "list": {
|
|
48
|
+
const users = getAllUsers();
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text: JSON.stringify(users, null, 2) }],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case "get": {
|
|
55
|
+
if (!input.userId) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text" as const, text: "userId is required for get action." }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const user = getUserById(input.userId);
|
|
61
|
+
if (!user) {
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text" as const, text: JSON.stringify(user, null, 2) }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case "create": {
|
|
72
|
+
if (!input.name) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text" as const, text: "name is required for create action." }],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const user = createUser({
|
|
79
|
+
name: input.name,
|
|
80
|
+
email: input.email,
|
|
81
|
+
role: input.role,
|
|
82
|
+
notes: input.notes,
|
|
83
|
+
slackUserId: input.slackUserId,
|
|
84
|
+
linearUserId: input.linearUserId,
|
|
85
|
+
githubUsername: input.githubUsername,
|
|
86
|
+
gitlabUsername: input.gitlabUsername,
|
|
87
|
+
emailAliases: input.emailAliases,
|
|
88
|
+
preferredChannel: input.preferredChannel,
|
|
89
|
+
timezone: input.timezone,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "text" as const,
|
|
95
|
+
text: `User created: ${JSON.stringify(user, null, 2)}`,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text" as const, text: `Failed to create user: ${message}` }],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "update": {
|
|
108
|
+
if (!input.userId) {
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text" as const, text: "userId is required for update action." }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const user = updateUser(input.userId, {
|
|
115
|
+
name: input.name,
|
|
116
|
+
email: input.email,
|
|
117
|
+
role: input.role,
|
|
118
|
+
notes: input.notes,
|
|
119
|
+
slackUserId: input.slackUserId,
|
|
120
|
+
linearUserId: input.linearUserId,
|
|
121
|
+
githubUsername: input.githubUsername,
|
|
122
|
+
gitlabUsername: input.gitlabUsername,
|
|
123
|
+
emailAliases: input.emailAliases,
|
|
124
|
+
preferredChannel: input.preferredChannel,
|
|
125
|
+
timezone: input.timezone,
|
|
126
|
+
});
|
|
127
|
+
if (!user) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: "text" as const,
|
|
136
|
+
text: `User updated: ${JSON.stringify(user, null, 2)}`,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text" as const, text: `Failed to update user: ${message}` }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "delete": {
|
|
149
|
+
if (!input.userId) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text" as const, text: "userId is required for delete action." }],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const deleted = deleteUser(input.userId);
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: "text" as const,
|
|
159
|
+
text: deleted ? `User ${input.userId} deleted.` : `User ${input.userId} not found.`,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
default:
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: "text" as const, text: `Unknown action: ${input.action}` }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { resolveUser } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
|
|
6
|
+
export const registerResolveUserTool = (server: McpServer) => {
|
|
7
|
+
createToolRegistrar(server)(
|
|
8
|
+
"resolve-user",
|
|
9
|
+
{
|
|
10
|
+
title: "Resolve user identity",
|
|
11
|
+
description:
|
|
12
|
+
"Look up a canonical user profile by any platform-specific identifier (Slack ID, Linear ID, GitHub username, email, or name). Returns the full user profile or null.",
|
|
13
|
+
annotations: { readOnlyHint: true },
|
|
14
|
+
inputSchema: z.object({
|
|
15
|
+
slackUserId: z.string().optional().describe("Slack user ID (e.g., U08NR6QD6CS)"),
|
|
16
|
+
linearUserId: z.string().optional().describe("Linear user UUID"),
|
|
17
|
+
githubUsername: z.string().optional().describe("GitHub username"),
|
|
18
|
+
gitlabUsername: z.string().optional().describe("GitLab username"),
|
|
19
|
+
email: z.string().optional().describe("Email address"),
|
|
20
|
+
name: z.string().optional().describe("Name (fuzzy substring match, lowest priority)"),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
async ({ slackUserId, linearUserId, githubUsername, gitlabUsername, email, name }) => {
|
|
24
|
+
if (!slackUserId && !linearUserId && !githubUsername && !gitlabUsername && !email && !name) {
|
|
25
|
+
return {
|
|
26
|
+
content: [
|
|
27
|
+
{
|
|
28
|
+
type: "text" as const,
|
|
29
|
+
text: "At least one search parameter is required.",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const user = resolveUser({
|
|
36
|
+
slackUserId,
|
|
37
|
+
linearUserId,
|
|
38
|
+
githubUsername,
|
|
39
|
+
gitlabUsername,
|
|
40
|
+
email,
|
|
41
|
+
name,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!user) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text" as const, text: "No user found matching the given criteria." }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text" as const, text: JSON.stringify(user, null, 2) }],
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
};
|