@malloy-publisher/server 0.0.207 → 0.0.209
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/dist/app/api-doc.yaml +84 -66
- package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/index-CCcHdeew.js +1760 -0
- package/dist/app/assets/index-CNFX-CGL.js +40 -0
- package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +26182 -16839
- package/package.json +14 -11
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/health.ts +6 -0
- package/src/materialization_metrics.ts +117 -34
- package/src/mcp/agent_server.protocol.spec.ts +78 -0
- package/src/mcp/agent_server.spec.ts +18 -0
- package/src/mcp/agent_server.ts +144 -0
- package/src/mcp/server.ts +3 -0
- package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
- package/src/mcp/skills/build_skills_bundle.ts +66 -0
- package/src/mcp/skills/skills_bundle.json +1 -0
- package/src/mcp/skills/skills_bundle.spec.ts +33 -0
- package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
- package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
- package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
- package/src/mcp/tools/docs_search_tool.ts +138 -0
- package/src/mcp/tools/get_context_eval.ts +126 -0
- package/src/mcp/tools/get_context_tool.spec.ts +44 -0
- package/src/mcp/tools/get_context_tool.ts +341 -0
- package/src/server-old.ts +2 -11
- package/src/server.ts +16 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/package.ts +41 -1
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
- package/dist/app/assets/HomePage-CGedji_w.js +0 -1
- package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
- package/dist/app/assets/index-DGGe8UpP.js +0 -40
- package/dist/app/assets/index-uu6UpHd2.js +0 -1812
- package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.209",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -36,16 +36,17 @@
|
|
|
36
36
|
"@azure/storage-blob": "^12.26.0",
|
|
37
37
|
"@duckdb/node-api": "1.5.3-r.2",
|
|
38
38
|
"@google-cloud/storage": "^7.16.0",
|
|
39
|
-
"@malloydata/db-bigquery": "^0.0.
|
|
40
|
-
"@malloydata/db-databricks": "^0.0.
|
|
41
|
-
"@malloydata/db-duckdb": "^0.0.
|
|
42
|
-
"@malloydata/db-mysql": "^0.0.
|
|
43
|
-
"@malloydata/db-postgres": "^0.0.
|
|
44
|
-
"@malloydata/db-
|
|
45
|
-
"@malloydata/db-
|
|
46
|
-
"@malloydata/
|
|
47
|
-
"@malloydata/malloy
|
|
48
|
-
"@malloydata/
|
|
39
|
+
"@malloydata/db-bigquery": "^0.0.415",
|
|
40
|
+
"@malloydata/db-databricks": "^0.0.415",
|
|
41
|
+
"@malloydata/db-duckdb": "^0.0.415",
|
|
42
|
+
"@malloydata/db-mysql": "^0.0.415",
|
|
43
|
+
"@malloydata/db-postgres": "^0.0.415",
|
|
44
|
+
"@malloydata/db-publisher": "^0.0.415",
|
|
45
|
+
"@malloydata/db-snowflake": "^0.0.415",
|
|
46
|
+
"@malloydata/db-trino": "^0.0.415",
|
|
47
|
+
"@malloydata/malloy": "^0.0.415",
|
|
48
|
+
"@malloydata/malloy-sql": "^0.0.415",
|
|
49
|
+
"@malloydata/render-validator": "^0.0.415",
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
|
50
51
|
"@opentelemetry/api": "^1.9.0",
|
|
51
52
|
"@opentelemetry/auto-instrumentations-node": "^0.57.0",
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"globals": "^15.9.0",
|
|
66
67
|
"handlebars": "^4.7.8",
|
|
67
68
|
"http-proxy-middleware": "^3.0.5",
|
|
69
|
+
"lunr": "^2.3.6",
|
|
68
70
|
"morgan": "^1.10.0",
|
|
69
71
|
"node-cron": "^3.0.3",
|
|
70
72
|
"recursive-readdir": "^2.2.3",
|
|
@@ -80,6 +82,7 @@
|
|
|
80
82
|
"@types/bun": "^1.2.20",
|
|
81
83
|
"@types/cors": "^2.8.12",
|
|
82
84
|
"@types/express": "^4.17.14",
|
|
85
|
+
"@types/lunr": "^2.3.7",
|
|
83
86
|
"@types/morgan": "^1.9.9",
|
|
84
87
|
"@types/node": "^24.10.1",
|
|
85
88
|
"@types/node-cron": "^3.0.11",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as sinon from "sinon";
|
|
3
|
+
import { BadRequestError } from "../errors";
|
|
4
|
+
import type { MaterializationService } from "../service/materialization_service";
|
|
5
|
+
import { MaterializationController } from "./materialization.controller";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unit tests for {@link MaterializationController.createMaterialization}'s body
|
|
9
|
+
* validation. The service is stubbed so each test asserts the parsed options
|
|
10
|
+
* the controller forwards (or the rejection for a malformed body).
|
|
11
|
+
*/
|
|
12
|
+
function build() {
|
|
13
|
+
const createMaterialization = sinon.stub().resolves({ id: "m1" });
|
|
14
|
+
const service = {
|
|
15
|
+
createMaterialization,
|
|
16
|
+
} as unknown as MaterializationService;
|
|
17
|
+
const controller = new MaterializationController(service);
|
|
18
|
+
return { controller, createMaterialization };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parsed options the controller forwards for a given request body. */
|
|
22
|
+
async function parse(body: Record<string, unknown>) {
|
|
23
|
+
const { controller, createMaterialization } = build();
|
|
24
|
+
await controller.createMaterialization("env", "pkg", body);
|
|
25
|
+
return createMaterialization.firstCall.args[2];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("MaterializationController.createMaterialization validation", () => {
|
|
29
|
+
it("forwards an empty body as empty options", async () => {
|
|
30
|
+
expect(await parse({})).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("passes through forceRefresh and sourceNames", async () => {
|
|
34
|
+
expect(
|
|
35
|
+
await parse({ forceRefresh: true, sourceNames: ["a", "b"] }),
|
|
36
|
+
).toEqual({ forceRefresh: true, sourceNames: ["a", "b"] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a non-boolean forceRefresh", async () => {
|
|
40
|
+
const { controller } = build();
|
|
41
|
+
await expect(
|
|
42
|
+
controller.createMaterialization("env", "pkg", {
|
|
43
|
+
forceRefresh: "yes",
|
|
44
|
+
}),
|
|
45
|
+
).rejects.toThrow(BadRequestError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects sourceNames that is not an array of strings", async () => {
|
|
49
|
+
const { controller } = build();
|
|
50
|
+
await expect(
|
|
51
|
+
controller.createMaterialization("env", "pkg", {
|
|
52
|
+
sourceNames: [1, 2],
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow(BadRequestError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("flattens buildInstructions.sources into the instruction list", async () => {
|
|
58
|
+
const parsed = await parse({
|
|
59
|
+
buildInstructions: {
|
|
60
|
+
sources: [
|
|
61
|
+
{
|
|
62
|
+
buildId: "b1",
|
|
63
|
+
sourceID: "orders@m",
|
|
64
|
+
materializedTableId: "mt-1",
|
|
65
|
+
physicalTableName: "orders_v1",
|
|
66
|
+
realization: "COPY",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
expect(parsed).toEqual({
|
|
72
|
+
buildInstructions: [
|
|
73
|
+
{
|
|
74
|
+
buildId: "b1",
|
|
75
|
+
sourceID: "orders@m",
|
|
76
|
+
materializedTableId: "mt-1",
|
|
77
|
+
physicalTableName: "orders_v1",
|
|
78
|
+
realization: "COPY",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats null buildInstructions as absent (auto-run)", async () => {
|
|
85
|
+
expect(await parse({ buildInstructions: null })).toEqual({});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects buildInstructions without a non-empty sources array", async () => {
|
|
89
|
+
const { controller } = build();
|
|
90
|
+
await expect(
|
|
91
|
+
controller.createMaterialization("env", "pkg", {
|
|
92
|
+
buildInstructions: { sources: [] },
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toThrow(BadRequestError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects an instruction missing a required field", async () => {
|
|
98
|
+
const { controller } = build();
|
|
99
|
+
await expect(
|
|
100
|
+
controller.createMaterialization("env", "pkg", {
|
|
101
|
+
buildInstructions: {
|
|
102
|
+
sources: [{ buildId: "b1", materializedTableId: "mt-1" }],
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
).rejects.toThrow(BadRequestError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects an unrecognized realization", async () => {
|
|
109
|
+
const { controller } = build();
|
|
110
|
+
await expect(
|
|
111
|
+
controller.createMaterialization("env", "pkg", {
|
|
112
|
+
buildInstructions: {
|
|
113
|
+
sources: [
|
|
114
|
+
{
|
|
115
|
+
buildId: "b1",
|
|
116
|
+
materializedTableId: "mt-1",
|
|
117
|
+
physicalTableName: "orders_v1",
|
|
118
|
+
realization: "MERGE",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
).rejects.toThrow(BadRequestError);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -20,18 +20,20 @@ export class MaterializationController {
|
|
|
20
20
|
private validateCreateBody(body: Record<string, unknown>): {
|
|
21
21
|
forceRefresh?: boolean;
|
|
22
22
|
sourceNames?: string[];
|
|
23
|
-
|
|
23
|
+
buildInstructions?: BuildInstruction[];
|
|
24
24
|
} {
|
|
25
25
|
const result: {
|
|
26
26
|
forceRefresh?: boolean;
|
|
27
27
|
sourceNames?: string[];
|
|
28
|
-
|
|
28
|
+
buildInstructions?: BuildInstruction[];
|
|
29
29
|
} = {};
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
result.
|
|
30
|
+
if (
|
|
31
|
+
body.buildInstructions !== undefined &&
|
|
32
|
+
body.buildInstructions !== null
|
|
33
|
+
) {
|
|
34
|
+
result.buildInstructions = this.validateBuildInstructions(
|
|
35
|
+
body.buildInstructions,
|
|
36
|
+
);
|
|
35
37
|
}
|
|
36
38
|
if (body.forceRefresh !== undefined) {
|
|
37
39
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -53,30 +55,24 @@ export class MaterializationController {
|
|
|
53
55
|
return result;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.validateBuildBody(body),
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private validateBuildBody(
|
|
71
|
-
body: Record<string, unknown>,
|
|
72
|
-
): BuildInstruction[] {
|
|
73
|
-
const sources = body.sources;
|
|
58
|
+
/**
|
|
59
|
+
* Validate the orchestrated `buildInstructions` payload (BuildInstructions:
|
|
60
|
+
* `{ sources: BuildInstruction[] }`) and flatten it to the instruction list
|
|
61
|
+
* the service consumes.
|
|
62
|
+
*/
|
|
63
|
+
private validateBuildInstructions(raw: unknown): BuildInstruction[] {
|
|
64
|
+
if (typeof raw !== "object" || raw === null) {
|
|
65
|
+
throw new BadRequestError("buildInstructions must be an object");
|
|
66
|
+
}
|
|
67
|
+
const sources = (raw as Record<string, unknown>).sources;
|
|
74
68
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
75
69
|
throw new BadRequestError(
|
|
76
|
-
"
|
|
70
|
+
"buildInstructions requires a non-empty 'sources' array of BuildInstruction",
|
|
77
71
|
);
|
|
78
72
|
}
|
|
79
|
-
return sources.map((
|
|
73
|
+
return sources.map((instruction) =>
|
|
74
|
+
this.validateInstruction(instruction),
|
|
75
|
+
);
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
private validateInstruction(raw: unknown): BuildInstruction {
|
package/src/health.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function registerSignalHandlers(
|
|
|
74
74
|
mcpServer: Server,
|
|
75
75
|
shutdownDrainDurationSeconds: number = 0,
|
|
76
76
|
shutdownGracefulCloseTimeoutSeconds: number = 0,
|
|
77
|
+
agentMcpServer?: Server,
|
|
77
78
|
): void {
|
|
78
79
|
// Keep the process alive on SIGTERM — do not close the server.
|
|
79
80
|
// K8s will SIGKILL after terminationGracePeriodSeconds (which cannot be caught).
|
|
@@ -96,6 +97,7 @@ export function registerSignalHandlers(
|
|
|
96
97
|
server,
|
|
97
98
|
mcpServer,
|
|
98
99
|
shutdownGracefulCloseTimeoutSeconds,
|
|
100
|
+
agentMcpServer,
|
|
99
101
|
);
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -114,6 +116,7 @@ export async function performGracefulShutdownAfterDrain(
|
|
|
114
116
|
server: Server,
|
|
115
117
|
mcpServer: Server,
|
|
116
118
|
shutdownGracefulCloseTimeoutSeconds: number,
|
|
119
|
+
agentMcpServer?: Server,
|
|
117
120
|
): Promise<void> {
|
|
118
121
|
const closeServer = (server: Server, name: string) =>
|
|
119
122
|
new Promise<void>((resolve) => {
|
|
@@ -134,6 +137,9 @@ export async function performGracefulShutdownAfterDrain(
|
|
|
134
137
|
await Promise.all([
|
|
135
138
|
closeServer(server, "Main server"),
|
|
136
139
|
closeServer(mcpServer, "MCP server"),
|
|
140
|
+
...(agentMcpServer
|
|
141
|
+
? [closeServer(agentMcpServer, "Agent MCP server")]
|
|
142
|
+
: []),
|
|
137
143
|
]);
|
|
138
144
|
|
|
139
145
|
try {
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centralized telemetry for
|
|
2
|
+
* Centralized telemetry for materialization builds.
|
|
3
3
|
*
|
|
4
|
-
* Operators need to answer "are builds completing, how long do
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* side by side.
|
|
4
|
+
* Operators need to answer "are builds completing, how long do they take, and
|
|
5
|
+
* where are they failing?" without grepping logs. The run counter carries
|
|
6
|
+
* `mode` (`auto`|`orchestrated`) and `outcome` (`success`|`failed`|`cancelled`);
|
|
7
|
+
* the run-duration histogram carries `mode` so a dashboard can render auto-run
|
|
8
|
+
* vs orchestrated build latency side by side.
|
|
10
9
|
*
|
|
11
10
|
* Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
|
|
12
11
|
* created before `setGlobalMeterProvider` bind to a NoOp meter
|
|
@@ -15,58 +14,138 @@
|
|
|
15
14
|
|
|
16
15
|
import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
|
|
17
16
|
|
|
18
|
-
export type
|
|
17
|
+
export type MaterializationMode = "auto" | "orchestrated";
|
|
19
18
|
export type MaterializationOutcome = "success" | "failed" | "cancelled";
|
|
20
19
|
/** Manifest bind outcome: timeout is split out from generic failure on purpose. */
|
|
21
20
|
export type ManifestBindOutcome = "success" | "failure" | "timeout";
|
|
22
21
|
|
|
23
|
-
let
|
|
24
|
-
let
|
|
22
|
+
let runCounter: Counter | null = null;
|
|
23
|
+
let runDuration: Histogram | null = null;
|
|
24
|
+
let sourcesCounter: Counter | null = null;
|
|
25
|
+
let buildPlanComputeDuration: Histogram | null = null;
|
|
26
|
+
let autoLoadCounter: Counter | null = null;
|
|
27
|
+
let connectionDigestSkipCounter: Counter | null = null;
|
|
25
28
|
let manifestBindCounter: Counter | null = null;
|
|
26
29
|
let sourceBuildDuration: Histogram | null = null;
|
|
27
30
|
let dropTablesCounter: Counter | null = null;
|
|
28
31
|
|
|
29
32
|
function ensureTelemetry(): { counter: Counter; duration: Histogram } {
|
|
30
|
-
if (
|
|
31
|
-
return { counter:
|
|
33
|
+
if (runCounter && runDuration) {
|
|
34
|
+
return { counter: runCounter, duration: runDuration };
|
|
32
35
|
}
|
|
33
36
|
const meter = metrics.getMeter("publisher");
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"Materialization rounds completed. Labels: round ('round1'|'round2'|'auto'), outcome ('success'|'failed'|'cancelled').",
|
|
40
|
-
},
|
|
41
|
-
);
|
|
37
|
+
if (!runCounter) {
|
|
38
|
+
runCounter = meter.createCounter("publisher_materialization_runs_total", {
|
|
39
|
+
description:
|
|
40
|
+
"Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'failed'|'cancelled').",
|
|
41
|
+
});
|
|
42
42
|
}
|
|
43
|
-
if (!
|
|
44
|
-
|
|
45
|
-
"
|
|
43
|
+
if (!runDuration) {
|
|
44
|
+
runDuration = meter.createHistogram(
|
|
45
|
+
"publisher_materialization_run_duration_ms",
|
|
46
46
|
{
|
|
47
47
|
description:
|
|
48
|
-
"Wall-clock duration of a materialization
|
|
48
|
+
"Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
|
|
49
49
|
unit: "ms",
|
|
50
50
|
},
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
-
return { counter:
|
|
53
|
+
return { counter: runCounter, duration: runDuration };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** Record the outcome and duration of a materialization
|
|
57
|
-
export function
|
|
58
|
-
|
|
56
|
+
/** Record the outcome and duration of a materialization build. */
|
|
57
|
+
export function recordMaterializationRun(
|
|
58
|
+
mode: MaterializationMode,
|
|
59
59
|
outcome: MaterializationOutcome,
|
|
60
60
|
durationMs: number,
|
|
61
61
|
): void {
|
|
62
62
|
const { counter, duration } = ensureTelemetry();
|
|
63
|
-
counter.add(1, {
|
|
64
|
-
duration.record(durationMs, {
|
|
63
|
+
counter.add(1, { mode, outcome });
|
|
64
|
+
duration.record(durationMs, { mode });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record how many persist sources a run built vs. reused (carried forward
|
|
69
|
+
* unchanged via skip-if-unchanged). Lets a dashboard show the reuse ratio,
|
|
70
|
+
* the main lever on materialization cost.
|
|
71
|
+
*/
|
|
72
|
+
export function recordSourcesOutcome(
|
|
73
|
+
outcome: "built" | "reused",
|
|
74
|
+
count: number,
|
|
75
|
+
): void {
|
|
76
|
+
if (count <= 0) return;
|
|
77
|
+
if (!sourcesCounter) {
|
|
78
|
+
sourcesCounter = metrics
|
|
79
|
+
.getMeter("publisher")
|
|
80
|
+
.createCounter("publisher_materialization_sources_total", {
|
|
81
|
+
description:
|
|
82
|
+
"Persist sources processed by a materialization run. Label: outcome ('built'|'reused').",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
sourcesCounter.add(count, { outcome });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Record the wall-clock cost of compiling a package's build plan
|
|
90
|
+
* (`Package.buildPlan`). This recompiles models at load, so it is worth
|
|
91
|
+
* tracking as a discrete cost separate from the build itself.
|
|
92
|
+
*/
|
|
93
|
+
export function recordBuildPlanComputeDuration(durationMs: number): void {
|
|
94
|
+
if (!buildPlanComputeDuration) {
|
|
95
|
+
buildPlanComputeDuration = metrics
|
|
96
|
+
.getMeter("publisher")
|
|
97
|
+
.createHistogram(
|
|
98
|
+
"publisher_materialization_build_plan_compute_duration_ms",
|
|
99
|
+
{
|
|
100
|
+
description:
|
|
101
|
+
"Wall-clock duration of compiling a package's build plan (Package.buildPlan).",
|
|
102
|
+
unit: "ms",
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
buildPlanComputeDuration.record(durationMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Record an auto-run manifest auto-load attempt. Auto-load is best-effort (a
|
|
111
|
+
* failure does not fail the run), so a failure here is otherwise invisible:
|
|
112
|
+
* the run is MANIFEST_FILE_READY but queries still resolve to the old tables.
|
|
113
|
+
*/
|
|
114
|
+
export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
|
|
115
|
+
if (!autoLoadCounter) {
|
|
116
|
+
autoLoadCounter = metrics
|
|
117
|
+
.getMeter("publisher")
|
|
118
|
+
.createCounter("publisher_materialization_auto_load_total", {
|
|
119
|
+
description:
|
|
120
|
+
"Auto-run manifest auto-load attempts. Label: outcome ('success'|'failure').",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
autoLoadCounter.add(1, { outcome });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Record that a connection digest could not be computed during build-plan
|
|
128
|
+
* compilation because the connection failed to resolve. The resulting buildIds
|
|
129
|
+
* are computed without a digest, so this is a correctness signal, not just noise.
|
|
130
|
+
*/
|
|
131
|
+
export function recordConnectionDigestSkipped(): void {
|
|
132
|
+
if (!connectionDigestSkipCounter) {
|
|
133
|
+
connectionDigestSkipCounter = metrics
|
|
134
|
+
.getMeter("publisher")
|
|
135
|
+
.createCounter(
|
|
136
|
+
"publisher_materialization_connection_digest_skipped_total",
|
|
137
|
+
{
|
|
138
|
+
description:
|
|
139
|
+
"Connection digests skipped during build-plan compile because the connection did not resolve.",
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
connectionDigestSkipCounter.add(1);
|
|
65
144
|
}
|
|
66
145
|
|
|
67
146
|
/**
|
|
68
|
-
* Record a manifest-bind attempt (publisher binding a
|
|
69
|
-
*
|
|
147
|
+
* Record a manifest-bind attempt (publisher binding a configured manifest to a
|
|
148
|
+
* package at load). `timeout` is distinguished from `failure` so operators can
|
|
70
149
|
* tell an unreachable/slow manifest store from a malformed manifest.
|
|
71
150
|
*/
|
|
72
151
|
export function recordManifestBind(outcome: ManifestBindOutcome): void {
|
|
@@ -113,8 +192,12 @@ export function recordDropTables(outcome: "success" | "failure"): void {
|
|
|
113
192
|
|
|
114
193
|
/** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
|
|
115
194
|
export function resetMaterializationTelemetryForTesting(): void {
|
|
116
|
-
|
|
117
|
-
|
|
195
|
+
runCounter = null;
|
|
196
|
+
runDuration = null;
|
|
197
|
+
sourcesCounter = null;
|
|
198
|
+
buildPlanComputeDuration = null;
|
|
199
|
+
autoLoadCounter = null;
|
|
200
|
+
connectionDigestSkipCounter = null;
|
|
118
201
|
manifestBindCounter = null;
|
|
119
202
|
sourceBuildDuration = null;
|
|
120
203
|
dropTablesCounter = null;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { initializeAgentMcpServer } from "./agent_server";
|
|
5
|
+
import type { EnvironmentStore } from "../service/environment_store";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* End-to-end coverage of the agent MCP server over the real MCP protocol, using
|
|
9
|
+
* the SDK's in-memory transport (no HTTP, no network, no DuckDB). Exercises tool
|
|
10
|
+
* registration, the dual-channel prompts, a real tool call (searchDocs over the
|
|
11
|
+
* bundled index), and the getContext error path.
|
|
12
|
+
*/
|
|
13
|
+
describe("agent MCP server over the MCP protocol (in-memory)", () => {
|
|
14
|
+
let client: Client;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
// searchDocs does not touch the store; getContext's error path only needs
|
|
18
|
+
// getEnvironment to reject, so a throwing stub is sufficient here.
|
|
19
|
+
const stubStore = {
|
|
20
|
+
getEnvironment: async (name: string): Promise<never> => {
|
|
21
|
+
throw new Error(`Environment not found: ${name}`);
|
|
22
|
+
},
|
|
23
|
+
} as unknown as EnvironmentStore;
|
|
24
|
+
|
|
25
|
+
const server = initializeAgentMcpServer(stubStore);
|
|
26
|
+
const [clientTransport, serverTransport] =
|
|
27
|
+
InMemoryTransport.createLinkedPair();
|
|
28
|
+
await server.connect(serverTransport);
|
|
29
|
+
client = new Client({ name: "agent-protocol-test", version: "0.0.0" });
|
|
30
|
+
await client.connect(clientTransport);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("exposes exactly the two agent tools", async () => {
|
|
34
|
+
const { tools } = await client.listTools();
|
|
35
|
+
expect(new Set(tools.map((t) => t.name))).toEqual(
|
|
36
|
+
new Set(["malloy_getContext", "malloy_searchDocs"]),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("exposes the skill set as dual-channel prompts", async () => {
|
|
41
|
+
const { prompts } = await client.listPrompts();
|
|
42
|
+
expect(prompts.length).toBeGreaterThanOrEqual(24);
|
|
43
|
+
expect(prompts.some((p) => p.name === "malloy-analysis")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("malloy_searchDocs returns relevant docs over the protocol", async () => {
|
|
47
|
+
const res = await client.callTool({
|
|
48
|
+
name: "malloy_searchDocs",
|
|
49
|
+
arguments: { query: "window functions" },
|
|
50
|
+
});
|
|
51
|
+
const content = res.content as Array<{ resource?: { text?: string } }>;
|
|
52
|
+
const results = JSON.parse(
|
|
53
|
+
content?.[0]?.resource?.text ?? "[]",
|
|
54
|
+
) as Array<{
|
|
55
|
+
url: string;
|
|
56
|
+
}>;
|
|
57
|
+
expect(results.length).toBeGreaterThan(0);
|
|
58
|
+
expect(results[0].url.length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("malloy_getContext fails gracefully (isError) for an unknown environment", async () => {
|
|
62
|
+
const res = await client.callTool({
|
|
63
|
+
name: "malloy_getContext",
|
|
64
|
+
arguments: {
|
|
65
|
+
environmentName: "nope",
|
|
66
|
+
packageName: "nope",
|
|
67
|
+
query: "x",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(res.isError).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("a skill prompt returns its body", async () => {
|
|
74
|
+
const res = await client.getPrompt({ name: "malloy-analysis" });
|
|
75
|
+
const content = res.messages[0].content as { text?: string };
|
|
76
|
+
expect((content.text ?? "").length).toBeGreaterThan(200);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { agentServerInfo, initializeAgentMcpServer } from "./agent_server";
|
|
3
|
+
import type { EnvironmentStore } from "../service/environment_store";
|
|
4
|
+
|
|
5
|
+
describe("agent MCP server", () => {
|
|
6
|
+
it("advertises a distinct server name from the core MCP server", () => {
|
|
7
|
+
expect(agentServerInfo.name).toBe("malloy-publisher-agent-mcp-server");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("initializes and registers its tools and skill prompts without throwing", () => {
|
|
11
|
+
// The store is captured for later handler calls, not used during
|
|
12
|
+
// registration, so a placeholder is sufficient for this smoke test.
|
|
13
|
+
const server = initializeAgentMcpServer(
|
|
14
|
+
{} as unknown as EnvironmentStore,
|
|
15
|
+
);
|
|
16
|
+
expect(server).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
});
|