@desplega.ai/agent-swarm 1.147.0 → 1.149.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 +13 -1
- package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
- package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
- package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
- package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
- package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
- package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
- package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
- package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
- package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
- package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
- package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
- package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
- package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
- package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
- package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
- package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
- package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
- package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
- package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
- package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
- package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
- package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
- package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
- package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
- package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
- package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
- package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
- package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
- package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
- package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
- package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
- package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
- package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
- package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
- package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
- package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
- package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
- package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
- package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
- package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
- package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
- package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
- package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
- package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
- package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
- package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
- package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
- package/dist/cli.js +16 -14
- package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
- package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
- package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
- package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
- package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
- package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
- package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
- package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
- package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
- package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
- package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
- package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
- package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
- package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
- package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
- package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
- package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
- package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
- package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
- package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
- package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
- package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
- package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
- package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
- package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
- package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
- package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
- package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
- package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
- package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
- package/openapi.json +46 -4
- package/package.json +3 -1
- package/src/agentmail/handlers.ts +5 -0
- package/src/automation-preflight-alert.ts +41 -0
- package/src/be/automation-preflight.ts +3 -3
- package/src/be/budget-refusal-notify.ts +1 -0
- package/src/be/db/tasks/read.ts +5 -0
- package/src/be/db.ts +132 -2
- package/src/be/migrations/150_deferred_task_waits.sql +18 -0
- package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
- package/src/be/migrations/152_routing_decisions.sql +44 -0
- package/src/be/scripts/typecheck.ts +33 -22
- package/src/be/seed-scripts/catalog/delegate.ts +5 -1
- package/src/be/seed-skills/bundled-files.generated.json +10 -0
- package/src/be/seed-skills/index.ts +3 -0
- package/src/be/steering.ts +1 -0
- package/src/be/swarm-config-guard.ts +11 -0
- package/src/commands/onboard/steps/post-task.tsx +1 -0
- package/src/github/handlers.ts +9 -0
- package/src/gitlab/handlers.ts +4 -0
- package/src/heartbeat/heartbeat.ts +8 -0
- package/src/http/approval-requests.ts +1 -0
- package/src/http/apps.ts +1 -0
- package/src/http/config.ts +15 -6
- package/src/http/mcp.ts +31 -4
- package/src/http/script-runs.ts +6 -0
- package/src/http/tasks.ts +52 -30
- package/src/integrations/kapso/inbound.ts +1 -0
- package/src/jira/sync.ts +2 -0
- package/src/linear/sync.ts +2 -0
- package/src/prompts/session-templates.ts +29 -15
- package/src/scheduler/deferred-task-waits.ts +123 -0
- package/src/scheduler/schedule-task.ts +42 -0
- package/src/scheduler/scheduler.ts +22 -31
- package/src/script-workflows/workflow-ctx.ts +2 -0
- package/src/scripts-runtime/types/stdlib.d.ts +38 -24
- package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
- package/src/server.ts +10 -1
- package/src/slack/actions.ts +1 -0
- package/src/slack/assistant.ts +2 -0
- package/src/slack/handlers.ts +3 -0
- package/src/slack/thread-buffer.ts +2 -0
- package/src/tasks/worker-follow-up.ts +9 -0
- package/src/tests/acp-adapter.test.ts +35 -6
- package/src/tests/acp-session-token.test.ts +67 -0
- package/src/tests/asset-key-api.test.ts +15 -2
- package/src/tests/asset-key-mcp.test.ts +2 -0
- package/src/tests/automation-preflight-alert.test.ts +91 -0
- package/src/tests/claude-worker-parity.test.ts +3 -0
- package/src/tests/config-session-auth.test.ts +143 -0
- package/src/tests/defer-task.test.ts +117 -6
- package/src/tests/deferred-task-wake.test.ts +456 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
- package/src/tests/http-api-integration.test.ts +33 -4
- package/src/tests/mcp-input-ergonomics.test.ts +386 -0
- package/src/tests/model-control.test.ts +18 -5
- package/src/tests/opencode-adapter.test.ts +2 -2
- package/src/tests/promote-draft-task-route.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +13 -4
- package/src/tests/rbac-wire-e2e.test.ts +2 -2
- package/src/tests/routing-decision-persistence.test.ts +520 -0
- package/src/tests/routing-reason-contract.test.ts +89 -0
- package/src/tests/routing-reason-inventory.test.ts +55 -0
- package/src/tests/schedule-target-type.test.ts +48 -1
- package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
- package/src/tests/scheduled-tasks.test.ts +9 -5
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-runs-http.test.ts +31 -0
- package/src/tests/scripts-typecheck.test.ts +11 -0
- package/src/tests/secret-scrubber.test.ts +12 -0
- package/src/tests/send-task-output-schema.test.ts +66 -1
- package/src/tests/send-task-requested-by.test.ts +1 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
- package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
- package/src/tests/swarm-tool-result-gate.test.ts +24 -0
- package/src/tests/task-tool-manifest.test.ts +47 -0
- package/src/tests/task-tool-preload-mcp.test.ts +125 -0
- package/src/tests/task-tools-ctx.test.ts +2 -0
- package/src/tests/tool-output-agent-id.test.ts +1 -0
- package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
- package/src/tests/workflow-agent-task.test.ts +24 -1
- package/src/tests/workflow-engine-v2.test.ts +58 -2
- package/src/tools/accept-steer.ts +0 -1
- package/src/tools/defer-task.ts +73 -21
- package/src/tools/memory-get.ts +10 -3
- package/src/tools/memory-rate.ts +19 -7
- package/src/tools/memory-search.ts +2 -1
- package/src/tools/memory-store.ts +25 -5
- package/src/tools/send-task.ts +40 -2
- package/src/tools/skills/skill-publish.ts +1 -0
- package/src/tools/store-progress.ts +78 -4
- package/src/tools/templates.ts +2 -1
- package/src/tools/utils.ts +33 -2
- package/src/types.ts +14 -1
- package/src/utils/acp-session-token.ts +14 -4
- package/src/utils/secret-scrubber.ts +2 -0
- package/src/utils/task-tool-manifest.ts +33 -0
- package/src/workflows/engine.ts +4 -1
- package/src/workflows/executors/agent-task.ts +7 -1
- package/templates/ai-toolbox.manifest.json +6 -1
- package/templates/skills/comms/config.json +18 -0
- package/templates/skills/comms/content.md +74 -0
- package/templates/skills/comms/files/references/ste-rules.md +73 -0
- package/templates/skills/comms/files/references/visual-shapes.md +112 -0
- package/templates/skills/swarm-scripts/SKILL.md +3 -2
- package/templates/skills/swarm-scripts/content.md +3 -2
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createTaskExtended,
|
|
7
|
+
getDbClient,
|
|
8
|
+
getTaskById,
|
|
9
|
+
initDb,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import { runMigrations } from "../be/migrations/runner";
|
|
12
|
+
import { CreateTaskOptionsSchema, RoutingReasonSchema } from "../types";
|
|
13
|
+
|
|
14
|
+
const migration = await Bun.file(
|
|
15
|
+
new URL("../be/migrations/152_routing_decisions.sql", import.meta.url),
|
|
16
|
+
).text();
|
|
17
|
+
|
|
18
|
+
type DecisionRow = {
|
|
19
|
+
task_id: string;
|
|
20
|
+
selected_agent_id: string | null;
|
|
21
|
+
captured_at: string;
|
|
22
|
+
worker_statuses: string;
|
|
23
|
+
continuity_candidates: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Candidate = { taskId: string; agentId: string } | null;
|
|
27
|
+
|
|
28
|
+
type ContinuityCandidates = {
|
|
29
|
+
samePr: Candidate;
|
|
30
|
+
sameThread: Candidate;
|
|
31
|
+
sameRepo: Candidate;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type WorkerStatus = {
|
|
35
|
+
agentId: string;
|
|
36
|
+
status: string;
|
|
37
|
+
activeTaskCount: number;
|
|
38
|
+
openTaskCount: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const routingHolderSql = `CASE
|
|
42
|
+
WHEN status IN ('draft', 'offered', 'reviewing') AND offeredTo IS NOT NULL THEN offeredTo
|
|
43
|
+
ELSE COALESCE(agentId, offeredTo)
|
|
44
|
+
END`;
|
|
45
|
+
|
|
46
|
+
function expectIndexedContinuityPlan(
|
|
47
|
+
database: Database,
|
|
48
|
+
where: string,
|
|
49
|
+
params: Array<string | number>,
|
|
50
|
+
indexName: string,
|
|
51
|
+
): void {
|
|
52
|
+
const plan = database
|
|
53
|
+
.query<{ detail: string }, Array<string | number>>(
|
|
54
|
+
`EXPLAIN QUERY PLAN
|
|
55
|
+
SELECT id AS taskId, ${routingHolderSql} AS agentId
|
|
56
|
+
FROM agent_tasks
|
|
57
|
+
WHERE ${where}
|
|
58
|
+
AND ${routingHolderSql} IN (SELECT id FROM agents WHERE isLead = 0)
|
|
59
|
+
ORDER BY createdAt DESC, rowid DESC
|
|
60
|
+
LIMIT 1`,
|
|
61
|
+
)
|
|
62
|
+
.all(...params)
|
|
63
|
+
.map((row) => row.detail)
|
|
64
|
+
.join("\n");
|
|
65
|
+
|
|
66
|
+
expect(plan).toContain(`USING INDEX ${indexName}`);
|
|
67
|
+
expect(plan).not.toContain("SCAN agent_tasks");
|
|
68
|
+
expect(plan).not.toContain("USE TEMP B-TREE");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getDecision(taskId: string): Promise<DecisionRow | null> {
|
|
72
|
+
return (
|
|
73
|
+
(await getDbClient().get<DecisionRow>("SELECT * FROM routing_decisions WHERE task_id = ?", [
|
|
74
|
+
taskId,
|
|
75
|
+
])) ?? null
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("routing decision migration and types", () => {
|
|
80
|
+
test("defines the canonical reasons and validates the optional create fields", () => {
|
|
81
|
+
expect(RoutingReasonSchema.options).toEqual([
|
|
82
|
+
"skill",
|
|
83
|
+
"continuity",
|
|
84
|
+
"overflow",
|
|
85
|
+
"human_pinned",
|
|
86
|
+
"reroute_fault",
|
|
87
|
+
]);
|
|
88
|
+
for (const routingReason of RoutingReasonSchema.options) {
|
|
89
|
+
expect(CreateTaskOptionsSchema.parse({ routingReason, routingNote: "short note" })).toEqual({
|
|
90
|
+
routingReason,
|
|
91
|
+
routingNote: "short note",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
expect(() => CreateTaskOptionsSchema.parse({ routingReason: "guess" })).toThrow();
|
|
95
|
+
expect(() => CreateTaskOptionsSchema.parse({ routingNote: "x".repeat(201) })).toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("upgrades an existing task table with checked scalars and cascading snapshots", () => {
|
|
99
|
+
const database = new Database(":memory:");
|
|
100
|
+
database.run("PRAGMA foreign_keys = ON");
|
|
101
|
+
try {
|
|
102
|
+
database.exec(`CREATE TABLE agent_tasks (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
task TEXT NOT NULL,
|
|
105
|
+
status TEXT NOT NULL,
|
|
106
|
+
source TEXT NOT NULL,
|
|
107
|
+
createdAt TEXT NOT NULL,
|
|
108
|
+
lastUpdatedAt TEXT NOT NULL,
|
|
109
|
+
agentId TEXT,
|
|
110
|
+
offeredTo TEXT,
|
|
111
|
+
vcsRepo TEXT,
|
|
112
|
+
vcsNumber INTEGER,
|
|
113
|
+
slackChannelId TEXT,
|
|
114
|
+
slackThreadTs TEXT,
|
|
115
|
+
agentmailThreadId TEXT
|
|
116
|
+
)`);
|
|
117
|
+
database.exec(
|
|
118
|
+
`INSERT INTO agent_tasks (id, task, status, source, createdAt, lastUpdatedAt)
|
|
119
|
+
VALUES ('legacy', 'legacy task', 'completed', 'mcp', '2026-01-01', '2026-01-01')`,
|
|
120
|
+
);
|
|
121
|
+
database.exec(migration);
|
|
122
|
+
|
|
123
|
+
const columns = database
|
|
124
|
+
.query<{ name: string }, []>("PRAGMA table_info(agent_tasks)")
|
|
125
|
+
.all()
|
|
126
|
+
.map((column) => column.name);
|
|
127
|
+
expect(columns).toContain("routing_reason");
|
|
128
|
+
expect(columns).toContain("routing_note");
|
|
129
|
+
expect(
|
|
130
|
+
database
|
|
131
|
+
.query<{ routing_reason: string | null; routing_note: string | null }, []>(
|
|
132
|
+
"SELECT routing_reason, routing_note FROM agent_tasks WHERE id = 'legacy'",
|
|
133
|
+
)
|
|
134
|
+
.get(),
|
|
135
|
+
).toEqual({ routing_reason: null, routing_note: null });
|
|
136
|
+
|
|
137
|
+
const insertTask = (reason: string | null, note: string | null) =>
|
|
138
|
+
database.run(
|
|
139
|
+
`INSERT INTO agent_tasks (
|
|
140
|
+
id, task, status, source, createdAt, lastUpdatedAt, routing_reason, routing_note
|
|
141
|
+
) VALUES (?, 'task', 'pending', 'mcp', '2026-01-01', '2026-01-01', ?, ?)`,
|
|
142
|
+
[crypto.randomUUID(), reason, note],
|
|
143
|
+
);
|
|
144
|
+
expect(() => insertTask("guess", null)).toThrow();
|
|
145
|
+
expect(() => insertTask("skill", "x".repeat(201))).toThrow();
|
|
146
|
+
|
|
147
|
+
const taskId = crypto.randomUUID();
|
|
148
|
+
database.run(
|
|
149
|
+
`INSERT INTO agent_tasks (
|
|
150
|
+
id, task, status, source, createdAt, lastUpdatedAt, routing_reason, routing_note
|
|
151
|
+
) VALUES (?, 'task', 'pending', 'mcp', '2026-01-01', '2026-01-01', 'skill', 'fit')`,
|
|
152
|
+
[taskId],
|
|
153
|
+
);
|
|
154
|
+
database.run(
|
|
155
|
+
`INSERT INTO routing_decisions (
|
|
156
|
+
task_id, selected_agent_id, captured_at, worker_statuses, continuity_candidates
|
|
157
|
+
) VALUES (?, 'worker', '2026-01-01', '[]', '{}')`,
|
|
158
|
+
[taskId],
|
|
159
|
+
);
|
|
160
|
+
database.run("DELETE FROM agent_tasks WHERE id = ?", [taskId]);
|
|
161
|
+
expect(
|
|
162
|
+
database.query("SELECT * FROM routing_decisions WHERE task_id = ?").get(taskId),
|
|
163
|
+
).toBeNull();
|
|
164
|
+
} finally {
|
|
165
|
+
database.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the migration runner applies 152 on a fresh database and reruns cleanly", () => {
|
|
170
|
+
const database = new Database(":memory:");
|
|
171
|
+
try {
|
|
172
|
+
runMigrations(database);
|
|
173
|
+
const applied = database
|
|
174
|
+
.query<{ name: string }, []>("SELECT name FROM _migrations WHERE version = 152")
|
|
175
|
+
.get();
|
|
176
|
+
expect(applied).toEqual({ name: "152_routing_decisions" });
|
|
177
|
+
expect(
|
|
178
|
+
database
|
|
179
|
+
.query<{ name: string }, []>(
|
|
180
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'routing_decisions'",
|
|
181
|
+
)
|
|
182
|
+
.get(),
|
|
183
|
+
).toEqual({ name: "routing_decisions" });
|
|
184
|
+
runMigrations(database);
|
|
185
|
+
expect(database.query("SELECT * FROM _migrations WHERE version = 152").all()).toHaveLength(1);
|
|
186
|
+
} finally {
|
|
187
|
+
database.close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("continuity lookups use context recency indexes without scanning task history", () => {
|
|
192
|
+
const database = new Database(":memory:");
|
|
193
|
+
try {
|
|
194
|
+
runMigrations(database);
|
|
195
|
+
expectIndexedContinuityPlan(
|
|
196
|
+
database,
|
|
197
|
+
"vcsRepo = ? AND vcsNumber = ?",
|
|
198
|
+
["desplega-ai/agent-swarm", 1477],
|
|
199
|
+
"idx_agent_tasks_routing_pr_recency",
|
|
200
|
+
);
|
|
201
|
+
expectIndexedContinuityPlan(
|
|
202
|
+
database,
|
|
203
|
+
"vcsRepo = ?",
|
|
204
|
+
["desplega-ai/agent-swarm"],
|
|
205
|
+
"idx_agent_tasks_routing_repo_recency",
|
|
206
|
+
);
|
|
207
|
+
expectIndexedContinuityPlan(
|
|
208
|
+
database,
|
|
209
|
+
"slackChannelId = ? AND slackThreadTs = ?",
|
|
210
|
+
["C1477", "1477.1"],
|
|
211
|
+
"idx_agent_tasks_routing_slack_thread_recency",
|
|
212
|
+
);
|
|
213
|
+
expectIndexedContinuityPlan(
|
|
214
|
+
database,
|
|
215
|
+
"agentmailThreadId = ?",
|
|
216
|
+
["thread-1477"],
|
|
217
|
+
"idx_agent_tasks_routing_agentmail_thread_recency",
|
|
218
|
+
);
|
|
219
|
+
} finally {
|
|
220
|
+
database.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("routing decision persistence", () => {
|
|
226
|
+
const agents = {
|
|
227
|
+
prOld: "00000000-0000-4000-8000-000000000101",
|
|
228
|
+
prLatest: "00000000-0000-4000-8000-000000000102",
|
|
229
|
+
thread: "00000000-0000-4000-8000-000000000103",
|
|
230
|
+
repo: "00000000-0000-4000-8000-000000000104",
|
|
231
|
+
selected: "00000000-0000-4000-8000-000000000105",
|
|
232
|
+
busy: "00000000-0000-4000-8000-000000000106",
|
|
233
|
+
waiting: "00000000-0000-4000-8000-000000000107",
|
|
234
|
+
offline: "00000000-0000-4000-8000-000000000108",
|
|
235
|
+
} as const;
|
|
236
|
+
|
|
237
|
+
beforeEach(async () => {
|
|
238
|
+
initDb(":memory:");
|
|
239
|
+
await createAgent({ name: "Lead", isLead: true, status: "idle" });
|
|
240
|
+
for (const [name, id] of Object.entries(agents)) {
|
|
241
|
+
const status =
|
|
242
|
+
name === "busy"
|
|
243
|
+
? "busy"
|
|
244
|
+
: name === "waiting"
|
|
245
|
+
? "waiting_for_credentials"
|
|
246
|
+
: name === "offline"
|
|
247
|
+
? "offline"
|
|
248
|
+
: "idle";
|
|
249
|
+
await createAgent({ id, name, isLead: false, status });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
afterEach(() => closeDb());
|
|
254
|
+
|
|
255
|
+
async function insertHistoricalTask(input: {
|
|
256
|
+
id: string;
|
|
257
|
+
agentId?: string;
|
|
258
|
+
offeredTo?: string;
|
|
259
|
+
status?: string;
|
|
260
|
+
createdAt: string;
|
|
261
|
+
vcsRepo?: string;
|
|
262
|
+
vcsNumber?: number;
|
|
263
|
+
slackChannelId?: string;
|
|
264
|
+
slackThreadTs?: string;
|
|
265
|
+
}): Promise<void> {
|
|
266
|
+
await getDbClient().run(
|
|
267
|
+
`INSERT INTO agent_tasks (
|
|
268
|
+
id, agentId, offeredTo, task, status, source, createdAt, lastUpdatedAt,
|
|
269
|
+
vcsRepo, vcsNumber, slackChannelId, slackThreadTs
|
|
270
|
+
) VALUES (?, ?, ?, ?, ?, 'mcp', ?, ?, ?, ?, ?, ?)`,
|
|
271
|
+
[
|
|
272
|
+
input.id,
|
|
273
|
+
input.agentId ?? null,
|
|
274
|
+
input.offeredTo ?? null,
|
|
275
|
+
input.id,
|
|
276
|
+
input.status ?? "completed",
|
|
277
|
+
input.createdAt,
|
|
278
|
+
input.createdAt,
|
|
279
|
+
input.vcsRepo ?? null,
|
|
280
|
+
input.vcsNumber ?? null,
|
|
281
|
+
input.slackChannelId ?? null,
|
|
282
|
+
input.slackThreadTs ?? null,
|
|
283
|
+
],
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
test("round-trips scalar fields, exact worker states, counts, and independent candidates", async () => {
|
|
288
|
+
await insertHistoricalTask({
|
|
289
|
+
id: "same-pr-old",
|
|
290
|
+
agentId: agents.prOld,
|
|
291
|
+
createdAt: "2026-01-02T00:00:00.000Z",
|
|
292
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
293
|
+
vcsNumber: 1477,
|
|
294
|
+
});
|
|
295
|
+
await insertHistoricalTask({
|
|
296
|
+
id: "same-pr-latest",
|
|
297
|
+
agentId: agents.prLatest,
|
|
298
|
+
createdAt: "2026-01-02T00:00:00.000Z",
|
|
299
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
300
|
+
vcsNumber: 1477,
|
|
301
|
+
});
|
|
302
|
+
await insertHistoricalTask({
|
|
303
|
+
id: "same-thread-latest",
|
|
304
|
+
agentId: agents.thread,
|
|
305
|
+
createdAt: "2026-01-03T00:00:00.000Z",
|
|
306
|
+
slackChannelId: "C1477",
|
|
307
|
+
slackThreadTs: "1477.1",
|
|
308
|
+
});
|
|
309
|
+
await insertHistoricalTask({
|
|
310
|
+
id: "same-repo-latest",
|
|
311
|
+
agentId: agents.repo,
|
|
312
|
+
createdAt: "2026-01-04T00:00:00.000Z",
|
|
313
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
314
|
+
vcsNumber: 999,
|
|
315
|
+
});
|
|
316
|
+
await insertHistoricalTask({
|
|
317
|
+
id: "newer-unassigned",
|
|
318
|
+
createdAt: "2026-01-05T00:00:00.000Z",
|
|
319
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
320
|
+
vcsNumber: 1477,
|
|
321
|
+
slackChannelId: "C1477",
|
|
322
|
+
slackThreadTs: "1477.1",
|
|
323
|
+
});
|
|
324
|
+
await insertHistoricalTask({
|
|
325
|
+
id: "busy-in-progress",
|
|
326
|
+
agentId: agents.busy,
|
|
327
|
+
status: "in_progress",
|
|
328
|
+
createdAt: "2026-01-06T00:00:00.000Z",
|
|
329
|
+
});
|
|
330
|
+
await insertHistoricalTask({
|
|
331
|
+
id: "busy-reviewing",
|
|
332
|
+
offeredTo: agents.busy,
|
|
333
|
+
status: "reviewing",
|
|
334
|
+
createdAt: "2026-01-07T00:00:00.000Z",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const created = await createTaskExtended("Persist routing telemetry", {
|
|
338
|
+
agentId: agents.selected,
|
|
339
|
+
routingReason: "skill",
|
|
340
|
+
routingNote: "Best fit for DB persistence",
|
|
341
|
+
vcsRepo: "desplega-ai/agent-swarm",
|
|
342
|
+
vcsNumber: 1477,
|
|
343
|
+
slackChannelId: "C1477",
|
|
344
|
+
slackThreadTs: "1477.1",
|
|
345
|
+
});
|
|
346
|
+
expect(created).toMatchObject({
|
|
347
|
+
routingReason: "skill",
|
|
348
|
+
routingNote: "Best fit for DB persistence",
|
|
349
|
+
});
|
|
350
|
+
expect(await getTaskById(created.id)).toMatchObject({
|
|
351
|
+
routingReason: "skill",
|
|
352
|
+
routingNote: "Best fit for DB persistence",
|
|
353
|
+
});
|
|
354
|
+
expect(
|
|
355
|
+
await getDbClient().get("SELECT routing_reason, routing_note FROM agent_tasks WHERE id = ?", [
|
|
356
|
+
created.id,
|
|
357
|
+
]),
|
|
358
|
+
).toEqual({
|
|
359
|
+
routing_reason: "skill",
|
|
360
|
+
routing_note: "Best fit for DB persistence",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const decision = await getDecision(created.id);
|
|
364
|
+
expect(decision?.selected_agent_id).toBe(agents.selected);
|
|
365
|
+
expect(new Date(decision!.captured_at).toISOString()).toBe(decision!.captured_at);
|
|
366
|
+
expect(JSON.parse(decision!.continuity_candidates) as ContinuityCandidates).toEqual({
|
|
367
|
+
samePr: { taskId: "same-pr-latest", agentId: agents.prLatest },
|
|
368
|
+
sameThread: { taskId: "same-thread-latest", agentId: agents.thread },
|
|
369
|
+
sameRepo: { taskId: "same-repo-latest", agentId: agents.repo },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const statuses = JSON.parse(decision!.worker_statuses) as WorkerStatus[];
|
|
373
|
+
expect(statuses).toHaveLength(Object.keys(agents).length);
|
|
374
|
+
expect(statuses.find((worker) => worker.agentId === agents.busy)).toEqual({
|
|
375
|
+
agentId: agents.busy,
|
|
376
|
+
status: "busy",
|
|
377
|
+
activeTaskCount: 2,
|
|
378
|
+
openTaskCount: 2,
|
|
379
|
+
});
|
|
380
|
+
expect(statuses.find((worker) => worker.agentId === agents.waiting)?.status).toBe(
|
|
381
|
+
"waiting_for_credentials",
|
|
382
|
+
);
|
|
383
|
+
expect(statuses.find((worker) => worker.agentId === agents.offline)?.status).toBe("offline");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("excludes the new pending task from open counts and survives a snapshot query failure", async () => {
|
|
387
|
+
const existing = await createTaskExtended("Already queued", { agentId: agents.selected });
|
|
388
|
+
const pending = await createTaskExtended("New pending task", {
|
|
389
|
+
agentId: agents.selected,
|
|
390
|
+
routingReason: "continuity",
|
|
391
|
+
});
|
|
392
|
+
const statuses = JSON.parse((await getDecision(pending.id))!.worker_statuses) as WorkerStatus[];
|
|
393
|
+
expect(statuses.find((worker) => worker.agentId === agents.selected)).toMatchObject({
|
|
394
|
+
openTaskCount: 1,
|
|
395
|
+
activeTaskCount: 0,
|
|
396
|
+
});
|
|
397
|
+
expect(pending.status).toBe("pending");
|
|
398
|
+
expect(existing.id).not.toBe(pending.id);
|
|
399
|
+
|
|
400
|
+
const client = getDbClient();
|
|
401
|
+
const query = client.query.bind(client);
|
|
402
|
+
const capture = spyOn(client, "query").mockImplementation((sql, params) => {
|
|
403
|
+
if (sql.includes("FROM agents agent")) throw new Error("snapshot query unavailable");
|
|
404
|
+
return query(sql, params);
|
|
405
|
+
});
|
|
406
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
407
|
+
try {
|
|
408
|
+
const created = await createTaskExtended("Survives capture failure", {
|
|
409
|
+
agentId: agents.selected,
|
|
410
|
+
routingReason: "skill",
|
|
411
|
+
});
|
|
412
|
+
expect(await getTaskById(created.id)).not.toBeNull();
|
|
413
|
+
expect(await getDecision(created.id)).toBeNull();
|
|
414
|
+
expect(
|
|
415
|
+
warning.mock.calls.some((call) => String(call[0]).includes("Failed to capture snapshot")),
|
|
416
|
+
).toBe(true);
|
|
417
|
+
} finally {
|
|
418
|
+
capture.mockRestore();
|
|
419
|
+
warning.mockRestore();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("uses offeredTo as the selected target", async () => {
|
|
424
|
+
const offered = await createTaskExtended("Offer telemetry", {
|
|
425
|
+
offeredTo: agents.selected,
|
|
426
|
+
routingReason: "human_pinned",
|
|
427
|
+
});
|
|
428
|
+
expect(offered).toMatchObject({ status: "offered", agentId: null });
|
|
429
|
+
expect((await getDecision(offered.id))?.selected_agent_id).toBe(agents.selected);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("captures inherited parent and source thread continuity after inheritance", async () => {
|
|
433
|
+
const parent = await createTaskExtended("Parent context", {
|
|
434
|
+
agentId: agents.prLatest,
|
|
435
|
+
vcsRepo: "desplega-ai/parent-context",
|
|
436
|
+
vcsNumber: 12,
|
|
437
|
+
slackChannelId: "CPARENT",
|
|
438
|
+
slackThreadTs: "12.1",
|
|
439
|
+
});
|
|
440
|
+
const child = await createTaskExtended("Parent continuation", {
|
|
441
|
+
agentId: agents.selected,
|
|
442
|
+
parentTaskId: parent.id,
|
|
443
|
+
routingReason: "continuity",
|
|
444
|
+
});
|
|
445
|
+
expect(JSON.parse((await getDecision(child.id))!.continuity_candidates)).toEqual({
|
|
446
|
+
samePr: { taskId: parent.id, agentId: agents.prLatest },
|
|
447
|
+
sameThread: { taskId: parent.id, agentId: agents.prLatest },
|
|
448
|
+
sameRepo: { taskId: parent.id, agentId: agents.prLatest },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const source = await createTaskExtended("Source context", {
|
|
452
|
+
agentId: agents.thread,
|
|
453
|
+
slackChannelId: "CSOURCE",
|
|
454
|
+
slackThreadTs: "13.1",
|
|
455
|
+
});
|
|
456
|
+
const sourced = await createTaskExtended("Source continuation", {
|
|
457
|
+
agentId: agents.selected,
|
|
458
|
+
creatorAgentId: agents.selected,
|
|
459
|
+
sourceTaskId: source.id,
|
|
460
|
+
routingReason: "continuity",
|
|
461
|
+
});
|
|
462
|
+
expect(
|
|
463
|
+
(JSON.parse((await getDecision(sourced.id))!.continuity_candidates) as ContinuityCandidates)
|
|
464
|
+
.sameThread,
|
|
465
|
+
).toEqual({ taskId: source.id, agentId: agents.thread });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("samples active counts before insert and ignores a failed snapshot write", async () => {
|
|
469
|
+
await getDbClient().run(`CREATE TRIGGER force_created_task_active
|
|
470
|
+
AFTER INSERT ON agent_tasks
|
|
471
|
+
WHEN NEW.task = 'pre-insert count probe'
|
|
472
|
+
BEGIN
|
|
473
|
+
UPDATE agent_tasks SET status = 'in_progress' WHERE id = NEW.id;
|
|
474
|
+
END`);
|
|
475
|
+
const probe = await createTaskExtended("pre-insert count probe", {
|
|
476
|
+
agentId: agents.selected,
|
|
477
|
+
routingReason: "skill",
|
|
478
|
+
});
|
|
479
|
+
const statuses = JSON.parse((await getDecision(probe.id))!.worker_statuses) as WorkerStatus[];
|
|
480
|
+
expect(statuses.find((worker) => worker.agentId === agents.selected)?.activeTaskCount).toBe(0);
|
|
481
|
+
expect((await getTaskById(probe.id))?.status).toBe("in_progress");
|
|
482
|
+
|
|
483
|
+
await getDbClient().run(`CREATE TRIGGER fail_routing_decision_write
|
|
484
|
+
BEFORE INSERT ON routing_decisions
|
|
485
|
+
BEGIN
|
|
486
|
+
SELECT RAISE(FAIL, 'snapshot unavailable');
|
|
487
|
+
END`);
|
|
488
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
489
|
+
try {
|
|
490
|
+
const created = await createTaskExtended("Task survives telemetry failure", {
|
|
491
|
+
agentId: agents.selected,
|
|
492
|
+
routingReason: "overflow",
|
|
493
|
+
});
|
|
494
|
+
expect(await getTaskById(created.id)).not.toBeNull();
|
|
495
|
+
expect(await getDecision(created.id)).toBeNull();
|
|
496
|
+
expect(
|
|
497
|
+
warning.mock.calls.some((call) => String(call[0]).includes("Failed to write snapshot")),
|
|
498
|
+
).toBe(true);
|
|
499
|
+
} finally {
|
|
500
|
+
warning.mockRestore();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("does not create another snapshot when tracker dedup returns an existing task", async () => {
|
|
505
|
+
const contextKey = "task:trackers:linear:DES-1477";
|
|
506
|
+
const first = await createTaskExtended("First tracker task", {
|
|
507
|
+
contextKey,
|
|
508
|
+
routingReason: "skill",
|
|
509
|
+
});
|
|
510
|
+
const duplicate = await createTaskExtended("Duplicate tracker task", {
|
|
511
|
+
contextKey,
|
|
512
|
+
agentId: agents.selected,
|
|
513
|
+
routingReason: "continuity",
|
|
514
|
+
});
|
|
515
|
+
expect(duplicate.id).toBe(first.id);
|
|
516
|
+
expect(
|
|
517
|
+
await getDbClient().get<{ count: number }>("SELECT COUNT(*) AS count FROM routing_decisions"),
|
|
518
|
+
).toEqual({ count: 1 });
|
|
519
|
+
});
|
|
520
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import delegate from "../be/seed-scripts/catalog/delegate";
|
|
3
|
+
import { sendTaskHandler, sendTaskInputSchema } from "../tools/send-task";
|
|
4
|
+
|
|
5
|
+
const ROUTING_REASONS = [
|
|
6
|
+
"skill",
|
|
7
|
+
"continuity",
|
|
8
|
+
"overflow",
|
|
9
|
+
"human_pinned",
|
|
10
|
+
"reroute_fault",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
describe("send-task routing decision input", () => {
|
|
14
|
+
test.each(ROUTING_REASONS)("accepts routingReason %s", (routingReason) => {
|
|
15
|
+
expect(
|
|
16
|
+
sendTaskInputSchema.safeParse({ task: "delegate", agentId: "worker", routingReason }).success,
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects an invalid routingReason", () => {
|
|
21
|
+
expect(
|
|
22
|
+
sendTaskInputSchema.safeParse({
|
|
23
|
+
task: "delegate",
|
|
24
|
+
agentId: "worker",
|
|
25
|
+
routingReason: "round_robin",
|
|
26
|
+
}).success,
|
|
27
|
+
).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("requires routingReason only when caller supplies agentId", () => {
|
|
31
|
+
expect(sendTaskInputSchema.safeParse({ task: "pool" }).success).toBe(true);
|
|
32
|
+
expect(sendTaskInputSchema.safeParse({ task: "delegate", agentId: "worker" }).success).toBe(
|
|
33
|
+
false,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("accepts a 200-character routingNote and rejects 201", () => {
|
|
38
|
+
expect(
|
|
39
|
+
sendTaskInputSchema.safeParse({
|
|
40
|
+
task: "delegate",
|
|
41
|
+
agentId: "worker",
|
|
42
|
+
routingReason: "skill",
|
|
43
|
+
routingNote: "x".repeat(200),
|
|
44
|
+
}).success,
|
|
45
|
+
).toBe(true);
|
|
46
|
+
expect(
|
|
47
|
+
sendTaskInputSchema.safeParse({
|
|
48
|
+
task: "delegate",
|
|
49
|
+
agentId: "worker",
|
|
50
|
+
routingReason: "skill",
|
|
51
|
+
routingNote: "x".repeat(201),
|
|
52
|
+
}).success,
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("handler rejects direct callers that bypass schema parsing", async () => {
|
|
57
|
+
const result = await sendTaskHandler({ kind: "owner", agentId: "sender" }, {
|
|
58
|
+
task: "delegate",
|
|
59
|
+
agentId: "worker",
|
|
60
|
+
offerMode: false,
|
|
61
|
+
allowDuplicate: false,
|
|
62
|
+
} as never);
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
expect(result.message).toContain("routingReason is required");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the delegate script requires the caller's reason and forwards it without relabeling", async () => {
|
|
69
|
+
const sent: unknown[] = [];
|
|
70
|
+
const ctx = {
|
|
71
|
+
swarm: {
|
|
72
|
+
swarm_get: async () => ({ agents: [{ id: "worker", name: "Worker" }] }),
|
|
73
|
+
task_send: async (args: unknown) => {
|
|
74
|
+
sent.push(args);
|
|
75
|
+
return { id: "task" };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
expect((await delegate({ agentName: "Worker", task: "work" }, ctx)).ok).toBe(false);
|
|
80
|
+
expect(sent).toHaveLength(0);
|
|
81
|
+
const result = await delegate(
|
|
82
|
+
{ agentName: "Worker", task: "work", routingReason: "continuity", routingNote: "same PR" },
|
|
83
|
+
ctx,
|
|
84
|
+
);
|
|
85
|
+
expect(result.ok).toBe(true);
|
|
86
|
+
expect(sent).toEqual([
|
|
87
|
+
{ agentId: "worker", task: "work", routingReason: "continuity", routingNote: "same PR" },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
const TASK_CREATORS = new Set(["createTaskExtended", "createTaskWithSiblingAwareness"]);
|
|
5
|
+
|
|
6
|
+
type Violation = { file: string; line: number; creator: string };
|
|
7
|
+
|
|
8
|
+
async function assignedCreatorsWithoutReason(): Promise<Violation[]> {
|
|
9
|
+
const violations: Violation[] = [];
|
|
10
|
+
const glob = new Bun.Glob("src/**/*.ts");
|
|
11
|
+
|
|
12
|
+
for await (const file of glob.scan({ cwd: ".", onlyFiles: true })) {
|
|
13
|
+
if (file.includes("/tests/") || file.endsWith(".test.ts")) continue;
|
|
14
|
+
const source = await Bun.file(file).text();
|
|
15
|
+
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
16
|
+
|
|
17
|
+
const visit = (node: ts.Node): void => {
|
|
18
|
+
if (ts.isCallExpression(node)) {
|
|
19
|
+
const creator = ts.isIdentifier(node.expression)
|
|
20
|
+
? node.expression.text
|
|
21
|
+
: ts.isPropertyAccessExpression(node.expression)
|
|
22
|
+
? node.expression.name.text
|
|
23
|
+
: "";
|
|
24
|
+
const options = node.arguments[1];
|
|
25
|
+
if (TASK_CREATORS.has(creator) && options && ts.isObjectLiteralExpression(options)) {
|
|
26
|
+
const propertyNames = new Set(
|
|
27
|
+
options.properties.flatMap((property) =>
|
|
28
|
+
(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
|
|
29
|
+
ts.isIdentifier(property.name)
|
|
30
|
+
? [property.name.text]
|
|
31
|
+
: [],
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
if (
|
|
35
|
+
(propertyNames.has("agentId") || propertyNames.has("offeredTo")) &&
|
|
36
|
+
!propertyNames.has("routingReason")
|
|
37
|
+
) {
|
|
38
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
39
|
+
violations.push({ file, line: line + 1, creator });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, visit);
|
|
44
|
+
};
|
|
45
|
+
visit(sourceFile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return violations;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("routing reason production inventory", () => {
|
|
52
|
+
test("every direct assigned/offer creator declares its reason at the call site", async () => {
|
|
53
|
+
expect(await assignedCreatorsWithoutReason()).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|