dxcomplete 0.1.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/.env.example +11 -0
- package/README.md +215 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +212 -0
- package/dist/http/server.d.ts +7 -0
- package/dist/http/server.js +236 -0
- package/dist/http/service.d.ts +7 -0
- package/dist/http/service.js +725 -0
- package/dist/init.d.ts +13 -0
- package/dist/init.js +128 -0
- package/dist/install-manifest.d.ts +25 -0
- package/dist/install-manifest.js +96 -0
- package/dist/mcp/docs.d.ts +98 -0
- package/dist/mcp/docs.js +438 -0
- package/dist/mcp/server.d.ts +20 -0
- package/dist/mcp/server.js +2345 -0
- package/dist/package-root.d.ts +2 -0
- package/dist/package-root.js +28 -0
- package/dist/runtime/actor.d.ts +14 -0
- package/dist/runtime/actor.js +42 -0
- package/dist/runtime/auth.d.ts +162 -0
- package/dist/runtime/auth.js +394 -0
- package/dist/runtime/check.d.ts +7 -0
- package/dist/runtime/check.js +16 -0
- package/dist/runtime/config.d.ts +17 -0
- package/dist/runtime/config.js +93 -0
- package/dist/runtime/mongo.d.ts +9 -0
- package/dist/runtime/mongo.js +56 -0
- package/dist/runtime/records.d.ts +336 -0
- package/dist/runtime/records.js +1463 -0
- package/dist/runtime/workspace.d.ts +19 -0
- package/dist/runtime/workspace.js +102 -0
- package/dist/upgrade.d.ts +20 -0
- package/dist/upgrade.js +246 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.js +119 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +12 -0
- package/docs/codex-integration.md +29 -0
- package/docs/cost-model.md +61 -0
- package/docs/decision-basis.md +57 -0
- package/docs/diagrams.md +31 -0
- package/docs/glossary.md +147 -0
- package/docs/index.md +60 -0
- package/docs/model.md +110 -0
- package/docs/open-questions.md +61 -0
- package/docs/roles.md +42 -0
- package/docs/taxonomy.md +96 -0
- package/docs/workflows.md +60 -0
- package/package.json +62 -0
- package/scripts/check-env-surface.mjs +136 -0
- package/scripts/check-public-copy.mjs +263 -0
- package/scripts/check-service-boundary.mjs +63 -0
- package/scripts/dogfood-work-order.mjs +506 -0
- package/scripts/smoke-mcp-http.mjs +3572 -0
- package/src/cli.ts +268 -0
- package/src/http/server.ts +314 -0
- package/src/http/service.ts +934 -0
- package/src/init.ts +227 -0
- package/src/install-manifest.ts +144 -0
- package/src/mcp/docs.ts +557 -0
- package/src/mcp/server.ts +3525 -0
- package/src/package-root.ts +31 -0
- package/src/runtime/actor.ts +61 -0
- package/src/runtime/auth.ts +673 -0
- package/src/runtime/check.ts +18 -0
- package/src/runtime/config.ts +128 -0
- package/src/runtime/mongo.ts +89 -0
- package/src/runtime/records.ts +2303 -0
- package/src/runtime/workspace.ts +155 -0
- package/src/upgrade.ts +356 -0
- package/src/validate.ts +139 -0
- package/src/version.ts +16 -0
- package/templates/github/workflows/dxcomplete.yml +16 -0
- package/templates/next/pages/api/auth/callback/google.js +12 -0
- package/templates/next/pages/api/dxcomplete/[...path].js +12 -0
- package/templates/next/pages/api/dxcomplete.js +12 -0
- package/templates/next/pages/api/mcp.js +12 -0
- package/templates/next/vercel.json +18 -0
- package/templates/process/README.md +38 -0
- package/templates/process/controls.yml +113 -0
- package/templates/process/cost-model.yml +71 -0
- package/templates/process/decision-basis.yml +53 -0
- package/templates/process/decisions/.gitkeep +1 -0
- package/templates/process/diagrams/00-decision-basis.mmd +24 -0
- package/templates/process/diagrams/00-overview.mmd +20 -0
- package/templates/process/diagrams/01-intake-triage.mmd +20 -0
- package/templates/process/diagrams/02-product-definition.mmd +14 -0
- package/templates/process/diagrams/03-engineering-execution.mmd +15 -0
- package/templates/process/diagrams/04-qa-verification.mmd +12 -0
- package/templates/process/diagrams/05-product-validation.mmd +12 -0
- package/templates/process/diagrams/06-change-release-control.mmd +16 -0
- package/templates/process/diagrams/07-deployment-operations.mmd +16 -0
- package/templates/process/diagrams/08-support-incident-management.mmd +16 -0
- package/templates/process/diagrams/09-problem-improvement.mmd +14 -0
- package/templates/process/diagrams/10-risk-control-management.mmd +14 -0
- package/templates/process/diagrams/11-audit-evidence-capture.mmd +13 -0
- package/templates/process/evidence/.gitkeep +1 -0
- package/templates/process/risks/.gitkeep +1 -0
- package/templates/process/roles.yml +96 -0
- package/templates/process/taxonomy.yml +514 -0
- package/templates/process/workflows.yml +210 -0
- package/website/.well-known/oauth-authorization-server +22 -0
- package/website/.well-known/oauth-protected-resource/api/dxcomplete/mcp +10 -0
- package/website/.well-known/oauth-protected-resource/api/mcp +10 -0
- package/website/README.md +12 -0
- package/website/app.js +36 -0
- package/website/flow.html +85 -0
- package/website/glossary.html +280 -0
- package/website/index.html +90 -0
- package/website/objects.html +287 -0
- package/website/outcomes.html +117 -0
- package/website/phase-build.html +101 -0
- package/website/phase-elicit.html +102 -0
- package/website/phase-go-live.html +103 -0
- package/website/phase-measure.html +93 -0
- package/website/phase-operate.html +102 -0
- package/website/phase-orient.html +92 -0
- package/website/phase-weigh.html +98 -0
- package/website/roles.html +52 -0
- package/website/styles.css +1169 -0
|
@@ -0,0 +1,3572 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import handleDxcompleteWorkspaceHttpRequest, { closeDxcompleteHttpRuntime } from "../dist/http/server.js";
|
|
8
|
+
import handleDxcompleteServiceRequest, { closeDxcompleteServiceRuntime } from "../dist/http/service.js";
|
|
9
|
+
import { createGoogleActorContext } from "../dist/runtime/actor.js";
|
|
10
|
+
import {
|
|
11
|
+
createOAuthAuthorizationCode,
|
|
12
|
+
createOAuthAuthorizationRequest,
|
|
13
|
+
ensureWorkspaceBootstrap,
|
|
14
|
+
issueMcpTokenPair,
|
|
15
|
+
upsertWorkspaceMembership
|
|
16
|
+
} from "../dist/runtime/auth.js";
|
|
17
|
+
import { connectRuntime } from "../dist/runtime/mongo.js";
|
|
18
|
+
import {
|
|
19
|
+
appendDxcompleteTicketReply,
|
|
20
|
+
archiveDxcompleteTicket,
|
|
21
|
+
archiveRecord,
|
|
22
|
+
DXCOMPLETE_TICKET_COLLECTION_NAME
|
|
23
|
+
} from "../dist/runtime/records.js";
|
|
24
|
+
import { loadWorkspaceConfig } from "../dist/runtime/workspace.js";
|
|
25
|
+
|
|
26
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const runId = `smoke-http-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
28
|
+
const expectedHostedToolCount = 49;
|
|
29
|
+
const smokeTimeoutMs = 60_000;
|
|
30
|
+
const smokeCleanupActorId = "dxcomplete-http-smoke";
|
|
31
|
+
const smokeStartedAt = Date.now();
|
|
32
|
+
let lastSmokeStepAt = smokeStartedAt;
|
|
33
|
+
const selectableSmokeAreas = ["surface", "docs", "records", "weigh", "change", "tickets", "journal"];
|
|
34
|
+
const smokeRecordTypes = [
|
|
35
|
+
"statements",
|
|
36
|
+
"journal_entries",
|
|
37
|
+
"environments",
|
|
38
|
+
"components",
|
|
39
|
+
"estimates",
|
|
40
|
+
"benefits",
|
|
41
|
+
"expectations",
|
|
42
|
+
"requirements",
|
|
43
|
+
"tasks",
|
|
44
|
+
"commitments",
|
|
45
|
+
"deferrals",
|
|
46
|
+
"changes",
|
|
47
|
+
"decisions",
|
|
48
|
+
"risks"
|
|
49
|
+
];
|
|
50
|
+
const smokeTitlePattern = /^DX Complete hosted MCP smoke /;
|
|
51
|
+
|
|
52
|
+
process.env.DXC_GOOGLE_CLIENT_ID ||= "dxcomplete-http-smoke-client-id";
|
|
53
|
+
process.env.DXC_GOOGLE_CLIENT_SECRET ||= "dxcomplete-http-smoke-client-secret";
|
|
54
|
+
process.env.DXC_SERVICE_PROVISIONING_SECRET ||= `dxcomplete-http-smoke-provisioning-${runId}`;
|
|
55
|
+
|
|
56
|
+
let runtime;
|
|
57
|
+
let workspaceConfig;
|
|
58
|
+
let server;
|
|
59
|
+
let serviceServer;
|
|
60
|
+
let client;
|
|
61
|
+
let transport;
|
|
62
|
+
let smokeActor;
|
|
63
|
+
let createdTicketId;
|
|
64
|
+
let createdStatementId;
|
|
65
|
+
let createdExpectationId;
|
|
66
|
+
let createdRequirementId;
|
|
67
|
+
let createdEstimateId;
|
|
68
|
+
let createdBenefitsId;
|
|
69
|
+
let createdCommitmentId;
|
|
70
|
+
let createdDeferralId;
|
|
71
|
+
let createdChangeId;
|
|
72
|
+
let createdDecisionId;
|
|
73
|
+
let createdServiceClientId;
|
|
74
|
+
|
|
75
|
+
const smokeSelection = parseSmokeSelection(process.argv.slice(2));
|
|
76
|
+
|
|
77
|
+
if (smokeSelection.runner === "selected") {
|
|
78
|
+
await runSelectedSmoke(smokeSelection);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
smokeStep("connecting runtime");
|
|
84
|
+
runtime = await connectRuntime({ envFile: path.join(rootDir, ".env.local") });
|
|
85
|
+
workspaceConfig = await loadWorkspaceConfig({ cwd: rootDir });
|
|
86
|
+
|
|
87
|
+
await ensureWorkspaceBootstrap(runtime.db, workspaceConfig, smokeCleanupActorId);
|
|
88
|
+
await archiveStaleSmokeArtifacts(runtime.db, workspaceConfig.workspaceId);
|
|
89
|
+
|
|
90
|
+
smokeStep("starting local HTTP MCP server");
|
|
91
|
+
server = createServer((req, res) => {
|
|
92
|
+
void handleDxcompleteWorkspaceHttpRequest(req, res);
|
|
93
|
+
});
|
|
94
|
+
const baseUrl = await listen(server);
|
|
95
|
+
const mcpUrl = `${baseUrl}/api/mcp`;
|
|
96
|
+
|
|
97
|
+
smokeStep("checking unauthenticated metadata");
|
|
98
|
+
const unauthorized = await fetchWithTimeout(mcpUrl, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
|
102
|
+
});
|
|
103
|
+
assert(unauthorized.status === 401, "Unauthenticated MCP request did not return 401.");
|
|
104
|
+
assert(
|
|
105
|
+
unauthorized.headers.get("www-authenticate")?.includes("/.well-known/oauth-protected-resource/api/mcp"),
|
|
106
|
+
"Unauthenticated MCP request did not advertise protected-resource metadata."
|
|
107
|
+
);
|
|
108
|
+
smokeStep("unauthenticated challenge ok");
|
|
109
|
+
|
|
110
|
+
const protectedMetadataResponse = await fetchWithTimeout(
|
|
111
|
+
`${baseUrl}/.well-known/oauth-protected-resource/api/mcp`
|
|
112
|
+
);
|
|
113
|
+
assert(protectedMetadataResponse.ok, "Protected-resource metadata was not reachable.");
|
|
114
|
+
const protectedMetadata = await protectedMetadataResponse.json();
|
|
115
|
+
assert(protectedMetadata.resource === mcpUrl, "Protected-resource metadata did not match the exact MCP URL.");
|
|
116
|
+
smokeStep("protected resource metadata ok");
|
|
117
|
+
|
|
118
|
+
const authMetadataResponse = await fetchWithTimeout(`${baseUrl}/.well-known/oauth-authorization-server`);
|
|
119
|
+
assert(authMetadataResponse.ok, "Authorization server metadata was not reachable.");
|
|
120
|
+
const authMetadata = await authMetadataResponse.json();
|
|
121
|
+
assert(
|
|
122
|
+
authMetadata.registration_endpoint === `${baseUrl}/api/dxcomplete/auth/register`,
|
|
123
|
+
"Authorization server metadata did not expose dynamic client registration."
|
|
124
|
+
);
|
|
125
|
+
smokeStep("authorization metadata ok");
|
|
126
|
+
|
|
127
|
+
const registrationResponse = await fetchWithTimeout(`${baseUrl}/api/dxcomplete/auth/register`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
client_name: `DX Complete HTTP smoke ${runId}`,
|
|
132
|
+
redirect_uris: ["https://client.example/callback"]
|
|
133
|
+
})
|
|
134
|
+
});
|
|
135
|
+
assert(registrationResponse.status === 201, "OAuth dynamic client registration failed.");
|
|
136
|
+
const registration = await registrationResponse.json();
|
|
137
|
+
smokeStep("dynamic client registration ok");
|
|
138
|
+
const authorizeUrl = new URL(`${baseUrl}/api/dxcomplete/auth/authorize`);
|
|
139
|
+
authorizeUrl.searchParams.set("client_id", registration.client_id);
|
|
140
|
+
authorizeUrl.searchParams.set("redirect_uri", "https://client.example/callback");
|
|
141
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
142
|
+
authorizeUrl.searchParams.set("code_challenge", "smoke-code-challenge");
|
|
143
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
144
|
+
authorizeUrl.searchParams.set("resource", mcpUrl);
|
|
145
|
+
|
|
146
|
+
const authorizeResponse = await fetchWithTimeout(authorizeUrl, { redirect: "manual" });
|
|
147
|
+
assert(authorizeResponse.status === 302, "OAuth authorize did not redirect to Google.");
|
|
148
|
+
const googleLocation = new URL(authorizeResponse.headers.get("location"));
|
|
149
|
+
assert(googleLocation.hostname === "accounts.google.com", "OAuth authorize did not redirect to Google.");
|
|
150
|
+
assert(
|
|
151
|
+
googleLocation.searchParams.get("redirect_uri") === `${baseUrl}/api/auth/callback/google`,
|
|
152
|
+
"OAuth authorize did not use /api/auth/callback/google as the Google callback."
|
|
153
|
+
);
|
|
154
|
+
smokeStep("authorization redirect ok");
|
|
155
|
+
|
|
156
|
+
smokeStep("issuing smoke actor token");
|
|
157
|
+
const actor = createGoogleActorContext({
|
|
158
|
+
email: `http-smoke-${runId}@example.com`,
|
|
159
|
+
subject: runId,
|
|
160
|
+
displayName: "HTTP Smoke Actor"
|
|
161
|
+
});
|
|
162
|
+
smokeActor = actor;
|
|
163
|
+
await upsertWorkspaceMembership(runtime.db, {
|
|
164
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
165
|
+
email: actor.email,
|
|
166
|
+
role: "member",
|
|
167
|
+
actorId: smokeCleanupActorId,
|
|
168
|
+
provider: "google",
|
|
169
|
+
providerSubject: actor.providerSubject
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const tokenExchangeVerifier = `smoke-token-verifier-${runId}`;
|
|
173
|
+
const tokenExchangeChallenge = createHash("sha256").update(tokenExchangeVerifier).digest("base64url");
|
|
174
|
+
const tokenExchangeRequest = await createOAuthAuthorizationRequest(runtime.db, {
|
|
175
|
+
clientId: registration.client_id,
|
|
176
|
+
redirectUri: "https://client.example/callback",
|
|
177
|
+
codeChallenge: tokenExchangeChallenge,
|
|
178
|
+
codeChallengeMethod: "S256",
|
|
179
|
+
scope: "mcp:tools",
|
|
180
|
+
resource: mcpUrl,
|
|
181
|
+
workspaceId: workspaceConfig.workspaceId
|
|
182
|
+
});
|
|
183
|
+
const tokenExchangeCode = await createOAuthAuthorizationCode(runtime.db, tokenExchangeRequest, actor);
|
|
184
|
+
const tokenExchangeResponse = await fetchWithTimeout(`${baseUrl}/api/dxcomplete/auth/token`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
187
|
+
body: new URLSearchParams({
|
|
188
|
+
grant_type: "authorization_code",
|
|
189
|
+
client_id: registration.client_id,
|
|
190
|
+
code: tokenExchangeCode,
|
|
191
|
+
redirect_uri: "https://client.example/callback",
|
|
192
|
+
code_verifier: tokenExchangeVerifier,
|
|
193
|
+
resource: mcpUrl
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
assert(tokenExchangeResponse.status === 200, "OAuth authorization_code token exchange failed.");
|
|
197
|
+
assert(
|
|
198
|
+
tokenExchangeResponse.headers.get("cache-control")?.includes("no-store"),
|
|
199
|
+
"OAuth token response did not include Cache-Control: no-store."
|
|
200
|
+
);
|
|
201
|
+
assert(tokenExchangeResponse.headers.get("pragma") === "no-cache", "OAuth token response did not include Pragma: no-cache.");
|
|
202
|
+
const tokenExchangeBody = await tokenExchangeResponse.json();
|
|
203
|
+
assert(typeof tokenExchangeBody.access_token === "string", "OAuth token response did not include an access token.");
|
|
204
|
+
await runtime.db.collection("oauth_authorization_requests").deleteOne({ _id: tokenExchangeRequest._id });
|
|
205
|
+
|
|
206
|
+
const tokenPair = await issueMcpTokenPair(runtime.db, {
|
|
207
|
+
clientId: smokeCleanupActorId,
|
|
208
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
209
|
+
actor,
|
|
210
|
+
resource: mcpUrl,
|
|
211
|
+
scope: "mcp:tools"
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const noAcceptResponse = await fetchWithTimeout(mcpUrl, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: {
|
|
217
|
+
authorization: `Bearer ${tokenPair.accessToken}`,
|
|
218
|
+
"content-type": "application/json",
|
|
219
|
+
"mcp-protocol-version": "2025-06-18"
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} })
|
|
222
|
+
});
|
|
223
|
+
assert(noAcceptResponse.status !== 406, "Authenticated MCP POST without explicit Accept returned 406.");
|
|
224
|
+
assert(
|
|
225
|
+
noAcceptResponse.headers.get("content-type")?.includes("application/json"),
|
|
226
|
+
"Authenticated MCP POST did not return a JSON response."
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const nonMemberActor = createGoogleActorContext({
|
|
230
|
+
email: `http-smoke-nonmember-${runId}@example.com`,
|
|
231
|
+
subject: `${runId}-nonmember`
|
|
232
|
+
});
|
|
233
|
+
const nonMemberTokenPair = await issueMcpTokenPair(runtime.db, {
|
|
234
|
+
clientId: smokeCleanupActorId,
|
|
235
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
236
|
+
actor: nonMemberActor,
|
|
237
|
+
resource: mcpUrl,
|
|
238
|
+
scope: "mcp:tools"
|
|
239
|
+
});
|
|
240
|
+
const forbidden = await fetchWithTimeout(mcpUrl, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
authorization: `Bearer ${nonMemberTokenPair.accessToken}`,
|
|
244
|
+
"content-type": "application/json"
|
|
245
|
+
},
|
|
246
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })
|
|
247
|
+
});
|
|
248
|
+
assert(forbidden.status === 403, "Non-member token did not return 403.");
|
|
249
|
+
|
|
250
|
+
smokeStep("connecting MCP client");
|
|
251
|
+
client = new Client({ name: "dxcomplete-smoke-mcp-http", version: "0.1.0" });
|
|
252
|
+
transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
|
253
|
+
requestInit: {
|
|
254
|
+
headers: {
|
|
255
|
+
authorization: `Bearer ${tokenPair.accessToken}`
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
await withTimeout(client.connect(transport), smokeTimeoutMs, "MCP client connect timed out.");
|
|
260
|
+
|
|
261
|
+
smokeStep("checking runtime status and tool surface");
|
|
262
|
+
const status = await callJsonTool("runtime_status", {});
|
|
263
|
+
assert(status.ok === true, "runtime_status did not return ok: true.");
|
|
264
|
+
assert(status.actor?.provider === "google", "Hosted runtime did not expose Google actor context.");
|
|
265
|
+
assert(status.actor?.email === actor.email, "Hosted runtime actor email did not match token actor.");
|
|
266
|
+
assert(status.workspace?.workspaceId === workspaceConfig.workspaceId, "Hosted runtime did not expose repo workspace config.");
|
|
267
|
+
assert(status.server?.toolCount === expectedHostedToolCount, `Expected ${expectedHostedToolCount} hosted tools.`);
|
|
268
|
+
assert(!status.server.tools.includes("create_workspace"), "Hosted surface should not expose create_workspace.");
|
|
269
|
+
assert(
|
|
270
|
+
status.server?.surfaceVersion === "dxc-mcp-surface",
|
|
271
|
+
"Hosted runtime did not expose the expected MCP surface version."
|
|
272
|
+
);
|
|
273
|
+
assert(status.server?.workspaceCompatibility === 1, "Hosted runtime did not expose the expected workspace compatibility version.");
|
|
274
|
+
assert(typeof status.server?.packageVersion === "string", "Hosted runtime did not expose a package version.");
|
|
275
|
+
assert(
|
|
276
|
+
typeof status.server?.surfaceFingerprint === "string" &&
|
|
277
|
+
/^[a-f0-9]{16}$/.test(status.server.surfaceFingerprint),
|
|
278
|
+
"Hosted runtime did not expose a stable MCP surface fingerprint."
|
|
279
|
+
);
|
|
280
|
+
assert(
|
|
281
|
+
Array.isArray(status.server?.surfaceIncludes) &&
|
|
282
|
+
status.server.surfaceIncludes.includes("tools") &&
|
|
283
|
+
status.server.surfaceIncludes.includes("toolSchemas") &&
|
|
284
|
+
status.server.surfaceIncludes.includes("processGuide") &&
|
|
285
|
+
status.server.surfaceIncludes.includes("docReferences"),
|
|
286
|
+
"Hosted runtime did not describe what the MCP surface fingerprint covers."
|
|
287
|
+
);
|
|
288
|
+
assert(
|
|
289
|
+
status.server?.http?.canonicalMcpUrl === mcpUrl,
|
|
290
|
+
"Hosted runtime did not report the canonical MCP URL."
|
|
291
|
+
);
|
|
292
|
+
assert(
|
|
293
|
+
status.server?.http?.protectedResourceMetadataUrl === `${baseUrl}/.well-known/oauth-protected-resource/api/mcp`,
|
|
294
|
+
"Hosted runtime did not report the protected-resource metadata URL."
|
|
295
|
+
);
|
|
296
|
+
assert(
|
|
297
|
+
status.server?.http?.googleCallbackUrl === `${baseUrl}/api/auth/callback/google`,
|
|
298
|
+
"Hosted runtime did not report the Google callback URL."
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const manifestByName = new Map(status.server.toolManifest.map((tool) => [tool.name, tool]));
|
|
302
|
+
assert(status.server.tools.includes("get_doc"), "Hosted surface is missing get_doc.");
|
|
303
|
+
for (const oldToolName of [
|
|
304
|
+
"create_intake_item",
|
|
305
|
+
"append_intake_item",
|
|
306
|
+
"get_intake_item",
|
|
307
|
+
"list_intake_items",
|
|
308
|
+
"archive_intake_item"
|
|
309
|
+
]) {
|
|
310
|
+
assert(
|
|
311
|
+
!status.server.tools.includes(oldToolName),
|
|
312
|
+
`Hosted runtime should not expose old intake tool ${oldToolName}.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
for (const ticketToolName of [
|
|
316
|
+
"create_dxcomplete_ticket",
|
|
317
|
+
"append_dxcomplete_ticket",
|
|
318
|
+
"list_dxcomplete_tickets",
|
|
319
|
+
"archive_dxcomplete_ticket",
|
|
320
|
+
"list_unread_dxcomplete_ticket_replies",
|
|
321
|
+
"read_dxcomplete_ticket"
|
|
322
|
+
]) {
|
|
323
|
+
assert(status.server.tools.includes(ticketToolName), `Hosted surface is missing ${ticketToolName}.`);
|
|
324
|
+
}
|
|
325
|
+
assert(
|
|
326
|
+
!status.server.tools.includes("mark_dxcomplete_ticket_replies_read"),
|
|
327
|
+
"Hosted runtime should not expose mark_dxcomplete_ticket_replies_read."
|
|
328
|
+
);
|
|
329
|
+
assert(
|
|
330
|
+
!status.server.tools.includes("get_dxcomplete_ticket"),
|
|
331
|
+
"Hosted runtime should not expose get_dxcomplete_ticket."
|
|
332
|
+
);
|
|
333
|
+
assert(
|
|
334
|
+
!status.server.tools.includes("get_next_steps"),
|
|
335
|
+
"Hosted runtime should not expose a server-side get_next_steps tool."
|
|
336
|
+
);
|
|
337
|
+
for (const expectationToolName of ["create_expectation", "update_expectation"]) {
|
|
338
|
+
assert(status.server.tools.includes(expectationToolName), `Hosted surface is missing ${expectationToolName}.`);
|
|
339
|
+
}
|
|
340
|
+
for (const statementToolName of ["create_statement", "update_statement"]) {
|
|
341
|
+
assert(status.server.tools.includes(statementToolName), `Hosted surface is missing ${statementToolName}.`);
|
|
342
|
+
}
|
|
343
|
+
assert(status.server.tools.includes("append_review_note"), "Hosted surface is missing append_review_note.");
|
|
344
|
+
assert(status.server.tools.includes("create_commitment"), "Hosted surface is missing create_commitment.");
|
|
345
|
+
assert(status.server.tools.includes("create_deferral"), "Hosted surface is missing create_deferral.");
|
|
346
|
+
assert(status.server.tools.includes("append_deferral_event"), "Hosted surface is missing append_deferral_event.");
|
|
347
|
+
assert(status.server.tools.includes("create_change"), "Hosted surface is missing create_change.");
|
|
348
|
+
assert(status.server.tools.includes("append_change_event"), "Hosted surface is missing append_change_event.");
|
|
349
|
+
assert(status.server.tools.includes("link_decision_input"), "Hosted surface is missing link_decision_input.");
|
|
350
|
+
assert(status.server.tools.includes("unlink_records"), "Hosted surface is missing unlink_records.");
|
|
351
|
+
assert(status.server.tools.includes("append_decision_entry"), "Hosted surface is missing append_decision_entry.");
|
|
352
|
+
assert(status.server.tools.includes("append_task_entry"), "Hosted surface is missing append_task_entry.");
|
|
353
|
+
assert(!status.server.tools.includes("update_decision"), "Hosted surface should not expose update_decision.");
|
|
354
|
+
assert(!status.server.tools.includes("update_task"), "Hosted surface should not expose update_task.");
|
|
355
|
+
assert(status.server.tools.includes("create_estimate"), "Hosted surface is missing create_estimate.");
|
|
356
|
+
assert(status.server.tools.includes("update_estimate"), "Hosted surface is missing update_estimate.");
|
|
357
|
+
assert(status.server.tools.includes("create_benefits"), "Hosted surface is missing create_benefits.");
|
|
358
|
+
assert(status.server.tools.includes("update_benefits"), "Hosted surface is missing update_benefits.");
|
|
359
|
+
for (const registryToolName of [
|
|
360
|
+
"create_environment",
|
|
361
|
+
"update_environment",
|
|
362
|
+
"list_environments",
|
|
363
|
+
"create_component",
|
|
364
|
+
"update_component",
|
|
365
|
+
"list_components"
|
|
366
|
+
]) {
|
|
367
|
+
assert(status.server.tools.includes(registryToolName), `Hosted surface is missing ${registryToolName}.`);
|
|
368
|
+
}
|
|
369
|
+
for (const journalToolName of [
|
|
370
|
+
"append_journal_note",
|
|
371
|
+
"read_journal",
|
|
372
|
+
"get_journal_entry",
|
|
373
|
+
"append_journal_summary"
|
|
374
|
+
]) {
|
|
375
|
+
assert(status.server.tools.includes(journalToolName), `Hosted surface is missing ${journalToolName}.`);
|
|
376
|
+
}
|
|
377
|
+
assert(
|
|
378
|
+
manifestByName.get("append_journal_note")?.description.includes("no better dedicated record home"),
|
|
379
|
+
"Hosted append_journal_note should describe Journal as fallback after dedicated records."
|
|
380
|
+
);
|
|
381
|
+
for (const removedToolName of [
|
|
382
|
+
"get_service_charter",
|
|
383
|
+
"update_service_charter",
|
|
384
|
+
"create_initiative",
|
|
385
|
+
"create_business_case",
|
|
386
|
+
"create_cost_baseline",
|
|
387
|
+
"create_cost_actual",
|
|
388
|
+
"create_benefit_measurement",
|
|
389
|
+
"create_cost_estimate",
|
|
390
|
+
"create_benefit_estimate"
|
|
391
|
+
]) {
|
|
392
|
+
assert(
|
|
393
|
+
!status.server.tools.includes(removedToolName),
|
|
394
|
+
`Hosted runtime should not expose removed tool ${removedToolName}.`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
assert(
|
|
398
|
+
manifestByName.get("get_doc")?.inputFields.join(",") === "page,term",
|
|
399
|
+
"Hosted get_doc should expose page and optional term inputs."
|
|
400
|
+
);
|
|
401
|
+
assert(
|
|
402
|
+
manifestByName.get("update_expectation")?.inputFields.includes("revisionNote"),
|
|
403
|
+
"Hosted update_expectation should expose revisionNote."
|
|
404
|
+
);
|
|
405
|
+
assert(
|
|
406
|
+
manifestByName.get("update_requirement")?.inputFields.includes("revisionNote"),
|
|
407
|
+
"Hosted update_requirement should expose revisionNote."
|
|
408
|
+
);
|
|
409
|
+
assert(
|
|
410
|
+
manifestByName.get("update_estimate")?.inputFields.includes("revisionNote"),
|
|
411
|
+
"Hosted update_estimate should expose revisionNote."
|
|
412
|
+
);
|
|
413
|
+
for (const toolName of [
|
|
414
|
+
"get_doc",
|
|
415
|
+
"create_record",
|
|
416
|
+
"get_record",
|
|
417
|
+
"create_statement",
|
|
418
|
+
"update_statement",
|
|
419
|
+
"create_expectation",
|
|
420
|
+
"update_expectation",
|
|
421
|
+
"append_review_note",
|
|
422
|
+
"create_environment",
|
|
423
|
+
"update_environment",
|
|
424
|
+
"list_environments",
|
|
425
|
+
"create_component",
|
|
426
|
+
"update_component",
|
|
427
|
+
"list_components",
|
|
428
|
+
"create_estimate",
|
|
429
|
+
"update_estimate",
|
|
430
|
+
"create_benefits",
|
|
431
|
+
"update_benefits",
|
|
432
|
+
"create_requirement",
|
|
433
|
+
"create_task",
|
|
434
|
+
"append_task_entry",
|
|
435
|
+
"create_commitment",
|
|
436
|
+
"create_deferral",
|
|
437
|
+
"append_deferral_event",
|
|
438
|
+
"create_change",
|
|
439
|
+
"append_change_event",
|
|
440
|
+
"create_decision",
|
|
441
|
+
"append_decision_entry",
|
|
442
|
+
"link_decision_input",
|
|
443
|
+
"append_journal_note",
|
|
444
|
+
"read_journal",
|
|
445
|
+
"get_journal_entry",
|
|
446
|
+
"append_journal_summary",
|
|
447
|
+
"list_records",
|
|
448
|
+
"list_linked_records",
|
|
449
|
+
"update_record",
|
|
450
|
+
"update_requirement",
|
|
451
|
+
"archive_record",
|
|
452
|
+
"link_records",
|
|
453
|
+
"unlink_records"
|
|
454
|
+
]) {
|
|
455
|
+
assert(
|
|
456
|
+
!manifestByName.get(toolName)?.inputFields.includes("workspaceId"),
|
|
457
|
+
`${toolName} should not expose workspaceId on the hosted surface.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const processGuide = await callJsonTool("get_process_guide", {});
|
|
462
|
+
assert(processGuide.surfaceVersion === status.server.surfaceVersion, "Hosted process guide surface version did not match runtime_status.");
|
|
463
|
+
assert(
|
|
464
|
+
processGuide.surfaceFingerprint === status.server.surfaceFingerprint,
|
|
465
|
+
"Hosted process guide surface fingerprint did not match runtime_status."
|
|
466
|
+
);
|
|
467
|
+
assert(
|
|
468
|
+
processGuide.status?.includes("lean guide") &&
|
|
469
|
+
processGuide.clientGuidance?.some((entry) => entry.includes("Use get_doc")),
|
|
470
|
+
"Hosted process guide did not describe itself as lean or point clients to get_doc."
|
|
471
|
+
);
|
|
472
|
+
assert(Array.isArray(processGuide.phases) && processGuide.phases.length === 7, "Hosted process guide did not return seven phases.");
|
|
473
|
+
assert(
|
|
474
|
+
processGuide.currentFlow?.join(",") === "Statement,Expectation,Requirement,Commitment",
|
|
475
|
+
"Hosted process guide did not return the expected Statement-first flow."
|
|
476
|
+
);
|
|
477
|
+
assert(
|
|
478
|
+
processGuide.phaseGuidance?.some((entry) => entry.includes("non-terminal") && entry.includes("re-enterable")),
|
|
479
|
+
"Hosted process guide did not describe phases as non-terminal and re-enterable."
|
|
480
|
+
);
|
|
481
|
+
assert(
|
|
482
|
+
processGuide.crossCuttingRecords?.some(
|
|
483
|
+
(entry) =>
|
|
484
|
+
entry.record === "Task" &&
|
|
485
|
+
entry.use.includes("Statement, Expectation, Requirement, Commitment")
|
|
486
|
+
),
|
|
487
|
+
"Hosted process guide should describe Task as cross-cutting without naming a separate sequence concept."
|
|
488
|
+
);
|
|
489
|
+
assert(
|
|
490
|
+
processGuide.recordRoutingGuidance?.sharpTest?.includes("Will anything reference or depend on this") &&
|
|
491
|
+
processGuide.recordRoutingGuidance?.routingOrder?.some(
|
|
492
|
+
(entry) => entry.use === "Use Journal." && entry.when.includes("no better home")
|
|
493
|
+
),
|
|
494
|
+
"Hosted process guide should include compact record-routing guidance."
|
|
495
|
+
);
|
|
496
|
+
assert(
|
|
497
|
+
processGuide.phases.map((phase) => phase.id).join(",") === "orient,elicit,weigh,build,go_live,operate,measure",
|
|
498
|
+
"Hosted process guide returned an unexpected phase order."
|
|
499
|
+
);
|
|
500
|
+
assert(
|
|
501
|
+
processGuide.nextStepGuidance?.responsibility?.includes("MCP client derives the next step") &&
|
|
502
|
+
processGuide.nextStepGuidance?.responsibility?.includes("does not compute or prescribe"),
|
|
503
|
+
"Hosted process guide did not describe next-step reasoning as client-side guidance."
|
|
504
|
+
);
|
|
505
|
+
assert(
|
|
506
|
+
processGuide.phases.every(
|
|
507
|
+
(phase) =>
|
|
508
|
+
phase.operatingGuidance?.clientRole &&
|
|
509
|
+
Array.isArray(phase.operatingGuidance.conductRules) &&
|
|
510
|
+
Array.isArray(phase.operatingGuidance.questionsToAsk) &&
|
|
511
|
+
Array.isArray(phase.operatingGuidance.expectedOutput) &&
|
|
512
|
+
phase.operatingGuidance.exitCheck &&
|
|
513
|
+
Array.isArray(phase.operatingGuidance.checkpointNotes)
|
|
514
|
+
),
|
|
515
|
+
"Hosted process guide did not return operating guidance for every phase."
|
|
516
|
+
);
|
|
517
|
+
const orientGuidance = processGuide.phases.find((phase) => phase.id === "orient")?.operatingGuidance;
|
|
518
|
+
const orientGuidanceText = JSON.stringify(orientGuidance).toLowerCase();
|
|
519
|
+
assert(orientGuidanceText.includes("statement"), "Orient guidance should tell the client to elicit Statement.");
|
|
520
|
+
assert(orientGuidanceText.includes("do not invent"), "Orient guidance should tell the client not to invent the raw input.");
|
|
521
|
+
assert(
|
|
522
|
+
orientGuidanceText.includes("confirm that wording before recording"),
|
|
523
|
+
"Orient guidance should describe capture-confirmation as conduct."
|
|
524
|
+
);
|
|
525
|
+
assert(
|
|
526
|
+
orientGuidanceText.includes("plain outcome language"),
|
|
527
|
+
"Orient guidance should require plain outcome language."
|
|
528
|
+
);
|
|
529
|
+
assert(
|
|
530
|
+
orientGuidanceText.includes("do not move into technical solution design"),
|
|
531
|
+
"Orient guidance should keep technical solution design out of Orient."
|
|
532
|
+
);
|
|
533
|
+
const expectationConcept = processGuide.processConcepts?.find((concept) => concept.name === "Expectation");
|
|
534
|
+
assert(
|
|
535
|
+
expectationConcept?.status === "runtime_record" && expectationConcept.currentRuntimeRecordType === "expectations",
|
|
536
|
+
"Expectation should be described as a runtime record."
|
|
537
|
+
);
|
|
538
|
+
const statementConcept = processGuide.processConcepts?.find((concept) => concept.name === "Statement");
|
|
539
|
+
assert(
|
|
540
|
+
statementConcept?.status === "runtime_record" && statementConcept.currentRuntimeRecordType === "statements",
|
|
541
|
+
"Statement should be described as a runtime record."
|
|
542
|
+
);
|
|
543
|
+
const voiceConcept = processGuide.processConcepts?.find((concept) => concept.name === "Voice");
|
|
544
|
+
assert(
|
|
545
|
+
voiceConcept === undefined,
|
|
546
|
+
"Voice should not remain a current process concept."
|
|
547
|
+
);
|
|
548
|
+
assert(
|
|
549
|
+
processGuide.runtimeRecordTypes?.includes("statements"),
|
|
550
|
+
"statements should appear as a current runtime record type."
|
|
551
|
+
);
|
|
552
|
+
assert(
|
|
553
|
+
processGuide.runtimeRecordTypes?.includes("expectations"),
|
|
554
|
+
"expectations should appear as a current runtime record type."
|
|
555
|
+
);
|
|
556
|
+
const checkpointGuidanceText = JSON.stringify(processGuide.checkpointGuidance ?? []).toLowerCase();
|
|
557
|
+
assert(
|
|
558
|
+
checkpointGuidanceText.includes("formally accepted as risk by the owner"),
|
|
559
|
+
"Hosted process guide should describe Owner-level risk acceptance."
|
|
560
|
+
);
|
|
561
|
+
assert(
|
|
562
|
+
checkpointGuidanceText.includes("proceeded past with open risk"),
|
|
563
|
+
"Hosted process guide should distinguish proceeding past open risk."
|
|
564
|
+
);
|
|
565
|
+
assert(
|
|
566
|
+
checkpointGuidanceText.includes("proceeding does not close or accept the risk"),
|
|
567
|
+
"Hosted process guide should say proceeding is not risk acceptance."
|
|
568
|
+
);
|
|
569
|
+
assert(
|
|
570
|
+
JSON.stringify(processGuide.clientGuidance ?? []).includes("link_decision_input") &&
|
|
571
|
+
JSON.stringify(processGuide.clientGuidance ?? []).includes("informed_by"),
|
|
572
|
+
"Hosted process guide should point clients to decision input links."
|
|
573
|
+
);
|
|
574
|
+
assert(
|
|
575
|
+
JSON.stringify(processGuide.recordGuidance ?? []).toLowerCase().includes("version history") &&
|
|
576
|
+
JSON.stringify(processGuide.clientGuidance ?? []).includes("update_expectation") &&
|
|
577
|
+
JSON.stringify(processGuide.clientGuidance ?? []).includes("update_requirement"),
|
|
578
|
+
"Hosted process guide should describe versioned Expectation and Requirement updates."
|
|
579
|
+
);
|
|
580
|
+
const processGuideText = JSON.stringify(processGuide).toLowerCase();
|
|
581
|
+
assert(processGuideText.includes("review notes"), "Hosted process guide should mention review notes.");
|
|
582
|
+
assert(
|
|
583
|
+
processGuideText.includes("do not require the owner to acknowledge or answer"),
|
|
584
|
+
"Hosted process guide should say important review notes do not require an Owner response."
|
|
585
|
+
);
|
|
586
|
+
assert(
|
|
587
|
+
processGuide.runtimeRecordTypes?.includes("changes"),
|
|
588
|
+
"changes should appear as a current runtime record type."
|
|
589
|
+
);
|
|
590
|
+
assert(
|
|
591
|
+
processGuide.runtimeRecordTypes?.includes("commitments") &&
|
|
592
|
+
processGuide.runtimeRecordTypes?.includes("deferrals"),
|
|
593
|
+
"commitments and deferrals should appear as current runtime record types."
|
|
594
|
+
);
|
|
595
|
+
assert(
|
|
596
|
+
processGuide.runtimeRecordTypes?.includes("estimates"),
|
|
597
|
+
"estimates should appear as a current runtime record type."
|
|
598
|
+
);
|
|
599
|
+
assert(
|
|
600
|
+
processGuide.runtimeRecordTypes?.includes("benefits"),
|
|
601
|
+
"benefits should appear as a current runtime record type."
|
|
602
|
+
);
|
|
603
|
+
assert(
|
|
604
|
+
processGuide.runtimeRecordTypes?.includes("environments") &&
|
|
605
|
+
processGuide.runtimeRecordTypes?.includes("components"),
|
|
606
|
+
"environments and components should appear as current runtime record types."
|
|
607
|
+
);
|
|
608
|
+
for (const removedRuntimeRecordType of [
|
|
609
|
+
"service_charters",
|
|
610
|
+
"business_cases",
|
|
611
|
+
"cost_baselines",
|
|
612
|
+
"cost_actuals",
|
|
613
|
+
"benefit_measurements",
|
|
614
|
+
"cost_estimates",
|
|
615
|
+
"benefit_estimates"
|
|
616
|
+
]) {
|
|
617
|
+
assert(
|
|
618
|
+
!processGuide.runtimeRecordTypes?.includes(removedRuntimeRecordType),
|
|
619
|
+
`Hosted process guide should not include removed runtime record type ${removedRuntimeRecordType}.`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
assert(
|
|
623
|
+
processGuideText.includes("commitment") &&
|
|
624
|
+
processGuideText.includes("deferral") &&
|
|
625
|
+
processGuideText.includes("phase outcome-neutral"),
|
|
626
|
+
"Hosted process guide should describe Weigh as Commitment-or-Deferral guidance."
|
|
627
|
+
);
|
|
628
|
+
assert(
|
|
629
|
+
processGuideText.includes("engineer cost estimate") &&
|
|
630
|
+
processGuideText.includes("owner-authored benefits") &&
|
|
631
|
+
processGuideText.includes("budget") &&
|
|
632
|
+
processGuideText.includes("informed_by"),
|
|
633
|
+
"Hosted process guide should describe separate Estimate and Benefits Weigh guidance."
|
|
634
|
+
);
|
|
635
|
+
assert(
|
|
636
|
+
processGuideText.includes("operations plan") &&
|
|
637
|
+
processGuideText.includes("append-only change events") &&
|
|
638
|
+
processGuideText.includes("does not perform or enforce"),
|
|
639
|
+
"Hosted process guide should describe Change as append-only record-layer service-change guidance."
|
|
640
|
+
);
|
|
641
|
+
assert(
|
|
642
|
+
processGuideText.includes("operational registry") &&
|
|
643
|
+
processGuideText.includes("environment") &&
|
|
644
|
+
processGuideText.includes("component"),
|
|
645
|
+
"Hosted process guide should describe Operational Registry routing."
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const rolesDoc = await callJsonTool("get_doc", { page: "roles" });
|
|
649
|
+
assert(rolesDoc.surfaceVersion === status.server.surfaceVersion, "Hosted get_doc surface version did not match runtime_status.");
|
|
650
|
+
assert(
|
|
651
|
+
rolesDoc.surfaceFingerprint === status.server.surfaceFingerprint,
|
|
652
|
+
"Hosted get_doc surface fingerprint did not match runtime_status."
|
|
653
|
+
);
|
|
654
|
+
const expectedRoleNames = [
|
|
655
|
+
"Owner",
|
|
656
|
+
"Engineer",
|
|
657
|
+
"Tester",
|
|
658
|
+
"Operator",
|
|
659
|
+
"Support Agent",
|
|
660
|
+
"End User"
|
|
661
|
+
];
|
|
662
|
+
const actualRoleNames = rolesDoc.roles?.map((role) => role.name) ?? [];
|
|
663
|
+
assert(
|
|
664
|
+
JSON.stringify(actualRoleNames) === JSON.stringify(expectedRoleNames),
|
|
665
|
+
`Hosted get_doc roles should return exactly the locked six-role model. Received: ${actualRoleNames.join(", ")}`
|
|
666
|
+
);
|
|
667
|
+
for (const roleName of expectedRoleNames) {
|
|
668
|
+
assert(
|
|
669
|
+
rolesDoc.roles?.some((role) => role.name === roleName),
|
|
670
|
+
`Hosted get_doc roles did not include ${roleName}.`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
for (const oldRoleName of ["Director", "Product Lead", "Product", "Engineering", "Coder", "QA", "Operations", "Support", "Admin", "Client/User"]) {
|
|
674
|
+
assert(!actualRoleNames.includes(oldRoleName), `Hosted get_doc roles should not include old role ${oldRoleName}.`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const glossaryDoc = await callJsonTool("get_doc", { page: "glossary" });
|
|
678
|
+
assert(
|
|
679
|
+
Array.isArray(glossaryDoc.terms) && glossaryDoc.terms.some((entry) => entry.term === "Expectation"),
|
|
680
|
+
"Hosted get_doc glossary should return glossary entries when no term is provided."
|
|
681
|
+
);
|
|
682
|
+
const glossaryTerms = glossaryDoc.terms?.map((entry) => entry.term) ?? [];
|
|
683
|
+
assert(glossaryTerms.includes("Change"), "Hosted get_doc glossary did not include Change.");
|
|
684
|
+
assert(!glossaryTerms.includes("Change Request"), "Hosted get_doc glossary should not include Change Request.");
|
|
685
|
+
assert(glossaryTerms.includes("Decision Input"), "Hosted get_doc glossary did not include Decision Input.");
|
|
686
|
+
assert(glossaryTerms.includes("Decision Entry"), "Hosted get_doc glossary did not include Decision Entry.");
|
|
687
|
+
assert(glossaryTerms.includes("Task Entry"), "Hosted get_doc glossary did not include Task Entry.");
|
|
688
|
+
assert(glossaryTerms.includes("Informed By"), "Hosted get_doc glossary did not include Informed By.");
|
|
689
|
+
assert(glossaryTerms.includes("Version History"), "Hosted get_doc glossary did not include Version History.");
|
|
690
|
+
for (const changeTerm of ["Change Plan", "Emergency Change", "Execution Plan", "Rollback Plan", "Veto"]) {
|
|
691
|
+
assert(glossaryTerms.includes(changeTerm), `Hosted get_doc glossary did not include ${changeTerm}.`);
|
|
692
|
+
}
|
|
693
|
+
for (const weighTerm of ["Commitment", "Condition", "Deferral", "Reservation", "Weigh"]) {
|
|
694
|
+
assert(glossaryTerms.includes(weighTerm), `Hosted get_doc glossary did not include ${weighTerm}.`);
|
|
695
|
+
}
|
|
696
|
+
for (const estimateTerm of ["Benefits", "Benefit Item", "Estimate", "Estimate Line Item", "Roll-up"]) {
|
|
697
|
+
assert(glossaryTerms.includes(estimateTerm), `Hosted get_doc glossary did not include ${estimateTerm}.`);
|
|
698
|
+
}
|
|
699
|
+
for (const registryTerm of ["Operational Registry", "Environment", "Component", "Locator", "Secret Pointer"]) {
|
|
700
|
+
assert(glossaryTerms.includes(registryTerm), `Hosted get_doc glossary did not include ${registryTerm}.`);
|
|
701
|
+
}
|
|
702
|
+
for (const removedTerm of ["Business Case", "Cost Estimate", "Benefit Estimate"]) {
|
|
703
|
+
assert(!glossaryTerms.includes(removedTerm), `Hosted get_doc glossary should not include removed term ${removedTerm}.`);
|
|
704
|
+
}
|
|
705
|
+
assert(!glossaryTerms.includes("Commit"), "Hosted get_doc glossary should not include Commit as a current phase term.");
|
|
706
|
+
assert(glossaryTerms.includes("Statement"), "Hosted get_doc glossary did not include Statement.");
|
|
707
|
+
assert(!glossaryTerms.includes("Voice"), "Hosted get_doc glossary should not include Voice.");
|
|
708
|
+
for (const roleTerm of ["Owner", "Engineer", "Tester", "Operator", "Support Agent", "End User"]) {
|
|
709
|
+
assert(glossaryTerms.includes(roleTerm), `Hosted get_doc glossary did not include role term ${roleTerm}.`);
|
|
710
|
+
}
|
|
711
|
+
for (const oldRoleTerm of ["Director", "Product Lead", "Product", "Engineering", "Coder", "QA", "Operations", "Support", "Admin", "Client/User"]) {
|
|
712
|
+
assert(!glossaryTerms.includes(oldRoleTerm), `Hosted get_doc glossary should not include old standalone role ${oldRoleTerm}.`);
|
|
713
|
+
}
|
|
714
|
+
const codexDoc = await callJsonTool("get_doc", { page: "glossary", term: "codex" });
|
|
715
|
+
assert(codexDoc.term?.definition.includes("not a role"), "Hosted get_doc glossary should describe Codex as not a role.");
|
|
716
|
+
const statementDoc = await callJsonTool("get_doc", { page: "glossary", term: "statement" });
|
|
717
|
+
assert(
|
|
718
|
+
statementDoc.term?.definition.includes("before they are interpreted or translated"),
|
|
719
|
+
"Hosted get_doc glossary should define Statement as raw uninterpreted input."
|
|
720
|
+
);
|
|
721
|
+
const reviewNoteDoc = await callJsonTool("get_doc", { page: "glossary", term: "review note" });
|
|
722
|
+
assert(
|
|
723
|
+
reviewNoteDoc.term?.definition.includes("does not block progress") &&
|
|
724
|
+
reviewNoteDoc.term?.definition.includes("require an Owner response"),
|
|
725
|
+
"Hosted get_doc glossary should describe review notes as non-blocking."
|
|
726
|
+
);
|
|
727
|
+
await expectToolError("get_doc", { page: "glossary", term: "voice" }, "Glossary term not found");
|
|
728
|
+
const riskAcceptanceDoc = await callJsonTool("get_doc", { page: "glossary", term: "risk acceptance" });
|
|
729
|
+
assert(
|
|
730
|
+
riskAcceptanceDoc.term?.definition.includes("Owner decision"),
|
|
731
|
+
"Hosted get_doc glossary should describe risk acceptance as Owner-level."
|
|
732
|
+
);
|
|
733
|
+
const proceedingPastDoc = await callJsonTool("get_doc", {
|
|
734
|
+
page: "glossary",
|
|
735
|
+
term: "proceeding past an open checkpoint"
|
|
736
|
+
});
|
|
737
|
+
assert(
|
|
738
|
+
proceedingPastDoc.term?.definition.includes("not formally accepted"),
|
|
739
|
+
"Hosted get_doc glossary should distinguish proceeding past an open checkpoint from risk acceptance."
|
|
740
|
+
);
|
|
741
|
+
const changeDoc = await callJsonTool("get_doc", { page: "glossary", term: "change" });
|
|
742
|
+
assert(
|
|
743
|
+
changeDoc.term?.definition.includes("discrete alteration") &&
|
|
744
|
+
changeDoc.term?.definition.includes("without enforcing the operation"),
|
|
745
|
+
"Hosted get_doc glossary should describe Change as a record-layer service change."
|
|
746
|
+
);
|
|
747
|
+
const decisionInputDoc = await callJsonTool("get_doc", { page: "glossary", term: "decision input" });
|
|
748
|
+
assert(
|
|
749
|
+
decisionInputDoc.term?.definition.includes("informed a decision"),
|
|
750
|
+
"Hosted get_doc glossary should define Decision Input."
|
|
751
|
+
);
|
|
752
|
+
const decisionDoc = await callJsonTool("get_doc", { page: "glossary", term: "decision" });
|
|
753
|
+
assert(
|
|
754
|
+
decisionDoc.term?.definition.includes("latest decision entry") &&
|
|
755
|
+
decisionDoc.term?.definition.includes("earlier arguments"),
|
|
756
|
+
"Hosted get_doc glossary should define Decision as latest-entry based."
|
|
757
|
+
);
|
|
758
|
+
const taskDoc = await callJsonTool("get_doc", { page: "glossary", term: "task" });
|
|
759
|
+
assert(
|
|
760
|
+
taskDoc.term?.definition.includes("latest status-change entry"),
|
|
761
|
+
"Hosted get_doc glossary should define Task as latest-status-entry based."
|
|
762
|
+
);
|
|
763
|
+
const versionHistoryDoc = await callJsonTool("get_doc", { page: "glossary", term: "version history" });
|
|
764
|
+
assert(
|
|
765
|
+
versionHistoryDoc.term?.definition.includes("Prior versions") &&
|
|
766
|
+
versionHistoryDoc.term?.definition.includes("expectation or requirement"),
|
|
767
|
+
"Hosted get_doc glossary should define Version History."
|
|
768
|
+
);
|
|
769
|
+
const commitmentDoc = await callJsonTool("get_doc", { page: "glossary", term: "commitment" });
|
|
770
|
+
assert(
|
|
771
|
+
commitmentDoc.term?.definition.includes("Owner record") &&
|
|
772
|
+
commitmentDoc.term?.definition.includes("reservations"),
|
|
773
|
+
"Hosted get_doc glossary should define Commitment."
|
|
774
|
+
);
|
|
775
|
+
const deferralDoc = await callJsonTool("get_doc", { page: "glossary", term: "deferral" });
|
|
776
|
+
assert(
|
|
777
|
+
deferralDoc.term?.definition.includes("not committing yet") &&
|
|
778
|
+
deferralDoc.term?.definition.includes("future Commitment"),
|
|
779
|
+
"Hosted get_doc glossary should define Deferral."
|
|
780
|
+
);
|
|
781
|
+
const estimateDoc = await callJsonTool("get_doc", { page: "glossary", term: "estimate" });
|
|
782
|
+
assert(
|
|
783
|
+
estimateDoc.term?.definition.includes("itemized") &&
|
|
784
|
+
estimateDoc.term?.definition.includes("cost estimate") &&
|
|
785
|
+
!estimateDoc.term?.definition.includes("benefit record"),
|
|
786
|
+
"Hosted get_doc glossary should define Estimate as an itemized cost record."
|
|
787
|
+
);
|
|
788
|
+
const estimateLineItemDoc = await callJsonTool("get_doc", { page: "glossary", term: "estimate line item" });
|
|
789
|
+
assert(
|
|
790
|
+
estimateLineItemDoc.term?.definition.includes("One cost item inside an Estimate") &&
|
|
791
|
+
!estimateLineItemDoc.term?.definition.includes("cost or benefit"),
|
|
792
|
+
"Hosted get_doc glossary should define Estimate Line Item."
|
|
793
|
+
);
|
|
794
|
+
const benefitsDoc = await callJsonTool("get_doc", { page: "glossary", term: "benefits" });
|
|
795
|
+
assert(
|
|
796
|
+
benefitsDoc.term?.definition.includes("Owner-authored benefit record") &&
|
|
797
|
+
benefitsDoc.term?.definition.includes("quantified or qualitative"),
|
|
798
|
+
"Hosted get_doc glossary should define Benefits."
|
|
799
|
+
);
|
|
800
|
+
const benefitItemDoc = await callJsonTool("get_doc", { page: "glossary", term: "benefit item" });
|
|
801
|
+
assert(
|
|
802
|
+
benefitItemDoc.term?.definition.includes("One item inside Benefits") &&
|
|
803
|
+
benefitItemDoc.term?.definition.includes("qualitative with no amount"),
|
|
804
|
+
"Hosted get_doc glossary should define Benefit Item."
|
|
805
|
+
);
|
|
806
|
+
const rollupDoc = await callJsonTool("get_doc", { page: "glossary", term: "roll-up" });
|
|
807
|
+
const rollupDefinition = rollupDoc.term?.definition.toLowerCase() ?? "";
|
|
808
|
+
assert(
|
|
809
|
+
rollupDefinition.includes("grouped totals") &&
|
|
810
|
+
rollupDefinition.includes("one-time") &&
|
|
811
|
+
rollupDefinition.includes("recurring") &&
|
|
812
|
+
rollupDefinition.includes("currencies"),
|
|
813
|
+
"Hosted get_doc glossary should define Roll-up."
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
const recordsDoc = await callJsonTool("get_doc", { page: "records" });
|
|
817
|
+
const recordNames = recordsDoc.groups?.flatMap((group) => group.records.map((record) => record.name)) ?? [];
|
|
818
|
+
assert(recordNames.includes("Change"), "Hosted get_doc records should include Change.");
|
|
819
|
+
assert(recordNames.includes("Environment"), "Hosted get_doc records should include Environment.");
|
|
820
|
+
assert(recordNames.includes("Component"), "Hosted get_doc records should include Component.");
|
|
821
|
+
assert(recordNames.includes("Commitment"), "Hosted get_doc records should include Commitment.");
|
|
822
|
+
assert(recordNames.includes("Deferral"), "Hosted get_doc records should include Deferral.");
|
|
823
|
+
assert(recordNames.includes("Estimate"), "Hosted get_doc records should include Estimate.");
|
|
824
|
+
assert(recordNames.includes("Benefits"), "Hosted get_doc records should include Benefits.");
|
|
825
|
+
assert(!recordNames.includes("Change Request"), "Hosted get_doc records should not include Change Request.");
|
|
826
|
+
const expectationRecord = recordsDoc.groups
|
|
827
|
+
?.flatMap((group) => group.records)
|
|
828
|
+
.find((record) => record.name === "Expectation");
|
|
829
|
+
const requirementRecord = recordsDoc.groups
|
|
830
|
+
?.flatMap((group) => group.records)
|
|
831
|
+
.find((record) => record.name === "Requirement");
|
|
832
|
+
assert(
|
|
833
|
+
expectationRecord?.summary?.includes("Prior versions") &&
|
|
834
|
+
requirementRecord?.summary?.includes("Prior versions"),
|
|
835
|
+
"Hosted get_doc records should describe Expectation and Requirement prior versions."
|
|
836
|
+
);
|
|
837
|
+
const decisionRecord = recordsDoc.groups
|
|
838
|
+
?.flatMap((group) => group.records)
|
|
839
|
+
.find((record) => record.name === "Decision");
|
|
840
|
+
const taskRecord = recordsDoc.groups
|
|
841
|
+
?.flatMap((group) => group.records)
|
|
842
|
+
.find((record) => record.name === "Task");
|
|
843
|
+
assert(
|
|
844
|
+
decisionRecord?.summary?.includes("ordered entries") &&
|
|
845
|
+
decisionRecord?.summary?.includes("records that informed it"),
|
|
846
|
+
"Hosted get_doc records should describe Decision entries and inputs."
|
|
847
|
+
);
|
|
848
|
+
assert(
|
|
849
|
+
taskRecord?.summary?.includes("latest status-change entry"),
|
|
850
|
+
"Hosted get_doc records should describe Task current status derivation."
|
|
851
|
+
);
|
|
852
|
+
smokeStep("process guide and documentation checks ok");
|
|
853
|
+
|
|
854
|
+
const expectationDoc = await callJsonTool("get_doc", { page: "glossary", term: "expectation" });
|
|
855
|
+
assert(expectationDoc.page === "glossary", "Hosted get_doc glossary term did not return the glossary page.");
|
|
856
|
+
assert(expectationDoc.term?.term === "Expectation", "Hosted get_doc glossary term did not return Expectation.");
|
|
857
|
+
assert(!Object.hasOwn(expectationDoc, "terms"), "Hosted get_doc glossary term should not return the full glossary.");
|
|
858
|
+
assert(
|
|
859
|
+
expectationDoc.surfaceFingerprint === status.server.surfaceFingerprint,
|
|
860
|
+
"Hosted get_doc glossary term surface fingerprint did not match runtime_status."
|
|
861
|
+
);
|
|
862
|
+
await expectToolError("get_doc", { page: "glossary", term: "not-a-term" }, "Glossary term not found");
|
|
863
|
+
|
|
864
|
+
const statement = await callJsonTool("create_statement", {
|
|
865
|
+
title: `DX Complete hosted MCP smoke statement ${runId}`,
|
|
866
|
+
statement: "The hosted MCP smoke run can track a first-class statement.",
|
|
867
|
+
source: "hosted smoke"
|
|
868
|
+
});
|
|
869
|
+
createdStatementId = statement._id;
|
|
870
|
+
assert(statement.recordType === "statements", "Hosted create_statement created the wrong record type.");
|
|
871
|
+
assert(statement.workspaceId === workspaceConfig.workspaceId, "Hosted Statement was not workspace-scoped.");
|
|
872
|
+
assert(statement.fields.statement?.includes("first-class statement"), "Hosted Statement text was not stored.");
|
|
873
|
+
|
|
874
|
+
const updatedStatement = await callJsonTool("update_statement", {
|
|
875
|
+
id: statement._id,
|
|
876
|
+
source: "updated hosted smoke",
|
|
877
|
+
revisionNote: "Smoke test statement source revision."
|
|
878
|
+
});
|
|
879
|
+
assert(updatedStatement.fields.source === "updated hosted smoke", "Hosted update_statement did not update source.");
|
|
880
|
+
assert(updatedStatement.fields.versionHistory?.length === 1, "Hosted update_statement did not append version history.");
|
|
881
|
+
|
|
882
|
+
const expectation = await callJsonTool("create_expectation", {
|
|
883
|
+
title: `DX Complete hosted MCP smoke expectation ${runId}`,
|
|
884
|
+
statementId: statement._id,
|
|
885
|
+
statement: "The hosted MCP smoke run can track a first-class expectation.",
|
|
886
|
+
successRecognition: "The expectation can be listed, updated, linked to a requirement, and archived."
|
|
887
|
+
});
|
|
888
|
+
createdExpectationId = expectation._id;
|
|
889
|
+
assert(expectation.recordType === "expectations", "Hosted create_expectation created the wrong record type.");
|
|
890
|
+
assert(expectation.workspaceId === workspaceConfig.workspaceId, "Hosted expectation was not workspace-scoped.");
|
|
891
|
+
assert(expectation.fields.statement?.includes("first-class expectation"), "Hosted expectation statement was not stored.");
|
|
892
|
+
assert(expectation.fields.approvalState === "draft", "Hosted expectation did not default/store approval state.");
|
|
893
|
+
|
|
894
|
+
const notApprovedExpectation = await callJsonTool("update_expectation", {
|
|
895
|
+
id: expectation._id,
|
|
896
|
+
approvalState: "not_approved",
|
|
897
|
+
revisionNote: "Smoke test expectation approval-state revision."
|
|
898
|
+
});
|
|
899
|
+
assert(
|
|
900
|
+
notApprovedExpectation.fields.approvalState === "not_approved",
|
|
901
|
+
"Hosted update_expectation did not accept not_approved approval state."
|
|
902
|
+
);
|
|
903
|
+
const firstExpectationVersionHistory = notApprovedExpectation.fields.versionHistory ?? [];
|
|
904
|
+
assert(firstExpectationVersionHistory.length === 1, "Hosted update_expectation did not append version history.");
|
|
905
|
+
const firstExpectationVersion = firstExpectationVersionHistory[0];
|
|
906
|
+
assert(
|
|
907
|
+
firstExpectationVersion.fromVersion === 1 &&
|
|
908
|
+
firstExpectationVersion.toVersion === 2 &&
|
|
909
|
+
firstExpectationVersion.changedFields.includes("fields.approvalState") &&
|
|
910
|
+
firstExpectationVersion.revisionNote === "Smoke test expectation approval-state revision.",
|
|
911
|
+
"Hosted expectation version history did not record version metadata."
|
|
912
|
+
);
|
|
913
|
+
assert(
|
|
914
|
+
firstExpectationVersion.previousSnapshot.fields.approvalState === "draft" &&
|
|
915
|
+
firstExpectationVersion.nextSnapshot.fields.approvalState === "not_approved",
|
|
916
|
+
"Hosted expectation version history did not preserve before/after snapshots."
|
|
917
|
+
);
|
|
918
|
+
assert(
|
|
919
|
+
!Object.hasOwn(firstExpectationVersion.previousSnapshot.fields, "versionHistory") &&
|
|
920
|
+
!Object.hasOwn(firstExpectationVersion.nextSnapshot.fields, "versionHistory") &&
|
|
921
|
+
!Object.hasOwn(firstExpectationVersion.previousSnapshot.fields, "reviewNotes") &&
|
|
922
|
+
!Object.hasOwn(firstExpectationVersion.nextSnapshot.fields, "reviewNotes"),
|
|
923
|
+
"Hosted expectation version snapshots should exclude versionHistory and reviewNotes."
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
const approvedAt = new Date().toISOString();
|
|
927
|
+
const updatedExpectation = await callJsonTool("update_expectation", {
|
|
928
|
+
id: expectation._id,
|
|
929
|
+
approvalState: "approved",
|
|
930
|
+
approvedBy: actor.actorId,
|
|
931
|
+
approvedAt,
|
|
932
|
+
source: "hosted-smoke",
|
|
933
|
+
revisionNote: "Smoke test expectation approval revision."
|
|
934
|
+
});
|
|
935
|
+
assert(updatedExpectation.fields.approvalState === "approved", "Hosted update_expectation did not update approval state.");
|
|
936
|
+
assert(updatedExpectation.fields.approvedBy === actor.actorId, "Hosted update_expectation did not update approvedBy.");
|
|
937
|
+
assert(
|
|
938
|
+
updatedExpectation.fields.versionHistory?.length === 2,
|
|
939
|
+
"Hosted update_expectation did not append a second version history entry."
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
const noOpExpectation = await callJsonTool("update_expectation", {
|
|
943
|
+
id: expectation._id,
|
|
944
|
+
approvalState: "approved",
|
|
945
|
+
approvedBy: actor.actorId,
|
|
946
|
+
approvedAt,
|
|
947
|
+
source: "hosted-smoke",
|
|
948
|
+
revisionNote: "No-op expectation update should not append history."
|
|
949
|
+
});
|
|
950
|
+
assert(
|
|
951
|
+
noOpExpectation.fields.versionHistory?.length === 2,
|
|
952
|
+
"Hosted no-op update_expectation should not append version history."
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
const listedExpectations = await callJsonTool("list_records", { recordType: "expectations", limit: 25 });
|
|
956
|
+
assert(
|
|
957
|
+
listedExpectations.some((record) => record._id === expectation._id),
|
|
958
|
+
"Hosted list_records did not include the created expectation."
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const fetchedExpectation = await callJsonTool("get_record", {
|
|
962
|
+
recordType: "expectations",
|
|
963
|
+
id: expectation._id
|
|
964
|
+
});
|
|
965
|
+
assert(fetchedExpectation._id === expectation._id, "Hosted get_record did not fetch the created expectation.");
|
|
966
|
+
|
|
967
|
+
const expectationWithReviewNote = await callJsonTool("append_review_note", {
|
|
968
|
+
recordType: "expectations",
|
|
969
|
+
id: expectation._id,
|
|
970
|
+
body: "Engineer review note for expectation.",
|
|
971
|
+
important: true
|
|
972
|
+
});
|
|
973
|
+
const expectationReviewNotes = expectationWithReviewNote.fields.reviewNotes ?? [];
|
|
974
|
+
assert(expectationReviewNotes.length === 1, "Hosted append_review_note did not append to expectation.");
|
|
975
|
+
assert(expectationReviewNotes[0].body === "Engineer review note for expectation.", "Hosted expectation review note body was not stored.");
|
|
976
|
+
assert(expectationReviewNotes[0].important === true, "Hosted expectation review note important flag was not stored.");
|
|
977
|
+
assert(expectationReviewNotes[0].createdBy === actor.actorId, "Hosted expectation review note was not attributed to the token actor.");
|
|
978
|
+
assert(
|
|
979
|
+
expectationWithReviewNote.fields.versionHistory?.length === 2,
|
|
980
|
+
"Hosted append_review_note should not append expectation version history."
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
await expectToolError(
|
|
984
|
+
"create_expectation",
|
|
985
|
+
{
|
|
986
|
+
title: `DX Complete hosted MCP bad expectation ${runId}`,
|
|
987
|
+
statement: "This should fail.",
|
|
988
|
+
fields: { initiativeId: "not-allowed-here" }
|
|
989
|
+
},
|
|
990
|
+
"removed Initiative layer"
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
await expectToolError(
|
|
994
|
+
"create_expectation",
|
|
995
|
+
{
|
|
996
|
+
title: `DX Complete hosted MCP accepted-risk expectation ${runId}`,
|
|
997
|
+
statement: "This should fail.",
|
|
998
|
+
approvalState: "accepted_risk"
|
|
999
|
+
},
|
|
1000
|
+
"not_approved"
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
await expectToolError(
|
|
1004
|
+
"update_expectation",
|
|
1005
|
+
{
|
|
1006
|
+
id: expectation._id,
|
|
1007
|
+
approvalState: "accepted_risk"
|
|
1008
|
+
},
|
|
1009
|
+
"not_approved"
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
await expectToolError(
|
|
1013
|
+
"create_expectation",
|
|
1014
|
+
{
|
|
1015
|
+
title: `DX Complete hosted MCP old-field expectation ${runId}`,
|
|
1016
|
+
statement: "This should fail.",
|
|
1017
|
+
fields: { confirmationState: "approved" }
|
|
1018
|
+
},
|
|
1019
|
+
"confirmationState is not part of the current Expectation model"
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
await expectToolError(
|
|
1023
|
+
"create_record",
|
|
1024
|
+
{
|
|
1025
|
+
recordType: "expectations",
|
|
1026
|
+
title: `DX Complete hosted MCP direct review notes expectation ${runId}`,
|
|
1027
|
+
fields: { reviewNotes: [] }
|
|
1028
|
+
},
|
|
1029
|
+
"append_review_note"
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
await expectToolError(
|
|
1033
|
+
"create_record",
|
|
1034
|
+
{
|
|
1035
|
+
recordType: "expectations",
|
|
1036
|
+
title: `DX Complete hosted MCP direct version history expectation ${runId}`,
|
|
1037
|
+
fields: { versionHistory: [] }
|
|
1038
|
+
},
|
|
1039
|
+
"versionHistory"
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
await expectToolError(
|
|
1043
|
+
"update_expectation",
|
|
1044
|
+
{
|
|
1045
|
+
id: expectation._id,
|
|
1046
|
+
fields: { reviewNotes: [] }
|
|
1047
|
+
},
|
|
1048
|
+
"append_review_note"
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
await expectToolError(
|
|
1052
|
+
"update_record",
|
|
1053
|
+
{
|
|
1054
|
+
recordType: "expectations",
|
|
1055
|
+
id: expectation._id,
|
|
1056
|
+
fields: { statement: "Generic update should not change managed expectation fields." }
|
|
1057
|
+
},
|
|
1058
|
+
"update_expectation"
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
await expectToolError(
|
|
1062
|
+
"update_record",
|
|
1063
|
+
{
|
|
1064
|
+
recordType: "expectations",
|
|
1065
|
+
id: expectation._id,
|
|
1066
|
+
fields: { versionHistory: [] }
|
|
1067
|
+
},
|
|
1068
|
+
"versionHistory"
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
await expectToolError(
|
|
1072
|
+
"update_record",
|
|
1073
|
+
{
|
|
1074
|
+
recordType: "expectations",
|
|
1075
|
+
id: expectation._id,
|
|
1076
|
+
unsetFields: ["reviewNotes"]
|
|
1077
|
+
},
|
|
1078
|
+
"append_review_note"
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
await expectToolError(
|
|
1082
|
+
"update_record",
|
|
1083
|
+
{
|
|
1084
|
+
recordType: "expectations",
|
|
1085
|
+
id: expectation._id,
|
|
1086
|
+
unsetFields: ["versionHistory"]
|
|
1087
|
+
},
|
|
1088
|
+
"versionHistory"
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
await expectToolError(
|
|
1092
|
+
"create_requirement",
|
|
1093
|
+
{
|
|
1094
|
+
title: `DX Complete hosted MCP bad requirement ${runId}`,
|
|
1095
|
+
statement: "This should fail.",
|
|
1096
|
+
fields: { expectationId: expectation._id }
|
|
1097
|
+
},
|
|
1098
|
+
"expectationId is a relationship input"
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
const requirement = await callJsonTool("create_requirement", {
|
|
1102
|
+
title: `DX Complete hosted MCP smoke requirement ${runId}`,
|
|
1103
|
+
statement: "A requirement can satisfy a first-class expectation.",
|
|
1104
|
+
expectationId: expectation._id,
|
|
1105
|
+
acceptanceCriteria: ["The requirement links to the expectation with relationship satisfies."]
|
|
1106
|
+
});
|
|
1107
|
+
createdRequirementId = requirement._id;
|
|
1108
|
+
assert(requirement.recordType === "requirements", "Hosted create_requirement created the wrong record type.");
|
|
1109
|
+
assert(
|
|
1110
|
+
requirement.links.some(
|
|
1111
|
+
(link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "satisfies"
|
|
1112
|
+
),
|
|
1113
|
+
"Hosted create_requirement did not create a satisfies link to the expectation."
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
const updatedRequirement = await callJsonTool("update_requirement", {
|
|
1117
|
+
id: requirement._id,
|
|
1118
|
+
priority: "high",
|
|
1119
|
+
status: "ready",
|
|
1120
|
+
acceptanceCriteria: [
|
|
1121
|
+
"The requirement links to the expectation with relationship satisfies.",
|
|
1122
|
+
"Requirement version history records before and after snapshots."
|
|
1123
|
+
],
|
|
1124
|
+
revisionNote: "Smoke test requirement readiness revision."
|
|
1125
|
+
});
|
|
1126
|
+
const requirementVersionHistory = updatedRequirement.fields.versionHistory ?? [];
|
|
1127
|
+
assert(requirementVersionHistory.length === 1, "Hosted update_requirement did not append version history.");
|
|
1128
|
+
const firstRequirementVersion = requirementVersionHistory[0];
|
|
1129
|
+
assert(
|
|
1130
|
+
firstRequirementVersion.fromVersion === 1 &&
|
|
1131
|
+
firstRequirementVersion.toVersion === 2 &&
|
|
1132
|
+
firstRequirementVersion.changedFields.includes("fields.status") &&
|
|
1133
|
+
firstRequirementVersion.changedFields.includes("fields.priority") &&
|
|
1134
|
+
firstRequirementVersion.revisionNote === "Smoke test requirement readiness revision.",
|
|
1135
|
+
"Hosted requirement version history did not record version metadata."
|
|
1136
|
+
);
|
|
1137
|
+
assert(
|
|
1138
|
+
firstRequirementVersion.previousSnapshot.fields.status === "draft" &&
|
|
1139
|
+
firstRequirementVersion.nextSnapshot.fields.status === "ready",
|
|
1140
|
+
"Hosted requirement version history did not preserve before/after snapshots."
|
|
1141
|
+
);
|
|
1142
|
+
assert(
|
|
1143
|
+
!Object.hasOwn(firstRequirementVersion.previousSnapshot.fields, "versionHistory") &&
|
|
1144
|
+
!Object.hasOwn(firstRequirementVersion.nextSnapshot.fields, "versionHistory") &&
|
|
1145
|
+
!Object.hasOwn(firstRequirementVersion.previousSnapshot.fields, "reviewNotes") &&
|
|
1146
|
+
!Object.hasOwn(firstRequirementVersion.nextSnapshot.fields, "reviewNotes"),
|
|
1147
|
+
"Hosted requirement version snapshots should exclude versionHistory and reviewNotes."
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
const noOpRequirement = await callJsonTool("update_requirement", {
|
|
1151
|
+
id: requirement._id,
|
|
1152
|
+
priority: "high",
|
|
1153
|
+
status: "ready",
|
|
1154
|
+
acceptanceCriteria: [
|
|
1155
|
+
"The requirement links to the expectation with relationship satisfies.",
|
|
1156
|
+
"Requirement version history records before and after snapshots."
|
|
1157
|
+
],
|
|
1158
|
+
revisionNote: "No-op requirement update should not append history."
|
|
1159
|
+
});
|
|
1160
|
+
assert(
|
|
1161
|
+
noOpRequirement.fields.versionHistory?.length === 1,
|
|
1162
|
+
"Hosted no-op update_requirement should not append version history."
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const requirementWithReviewNote = await callJsonTool("append_review_note", {
|
|
1166
|
+
recordType: "requirements",
|
|
1167
|
+
id: requirement._id,
|
|
1168
|
+
body: "Engineer review note for requirement."
|
|
1169
|
+
});
|
|
1170
|
+
const requirementReviewNotes = requirementWithReviewNote.fields.reviewNotes ?? [];
|
|
1171
|
+
assert(requirementReviewNotes.length === 1, "Hosted append_review_note did not append to requirement.");
|
|
1172
|
+
assert(requirementReviewNotes[0].important === undefined, "Hosted requirement review note should omit important when false.");
|
|
1173
|
+
assert(requirementReviewNotes[0].body === "Engineer review note for requirement.", "Hosted requirement review note body was not stored.");
|
|
1174
|
+
assert(
|
|
1175
|
+
requirementWithReviewNote.fields.versionHistory?.length === 1,
|
|
1176
|
+
"Hosted append_review_note should not append requirement version history."
|
|
1177
|
+
);
|
|
1178
|
+
|
|
1179
|
+
await expectToolError(
|
|
1180
|
+
"create_requirement",
|
|
1181
|
+
{
|
|
1182
|
+
title: `DX Complete hosted MCP direct review notes requirement ${runId}`,
|
|
1183
|
+
statement: "This should fail.",
|
|
1184
|
+
fields: { reviewNotes: [] }
|
|
1185
|
+
},
|
|
1186
|
+
"append_review_note"
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
await expectToolError(
|
|
1190
|
+
"update_record",
|
|
1191
|
+
{
|
|
1192
|
+
recordType: "requirements",
|
|
1193
|
+
id: requirement._id,
|
|
1194
|
+
fields: { acceptanceCriteria: ["Generic update should not change managed requirement fields."] }
|
|
1195
|
+
},
|
|
1196
|
+
"update_requirement"
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
await expectToolError(
|
|
1200
|
+
"update_record",
|
|
1201
|
+
{
|
|
1202
|
+
recordType: "requirements",
|
|
1203
|
+
id: requirement._id,
|
|
1204
|
+
unsetFields: ["versionHistory"]
|
|
1205
|
+
},
|
|
1206
|
+
"versionHistory"
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
const requirementLinks = await callJsonTool("list_linked_records", {
|
|
1210
|
+
recordType: "requirements",
|
|
1211
|
+
id: requirement._id,
|
|
1212
|
+
relationship: "satisfies"
|
|
1213
|
+
});
|
|
1214
|
+
assert(
|
|
1215
|
+
requirementLinks.outbound.some((entry) => entry.record._id === expectation._id),
|
|
1216
|
+
"Hosted list_linked_records did not return the linked expectation."
|
|
1217
|
+
);
|
|
1218
|
+
const expectationStatementLinks = await callJsonTool("list_linked_records", {
|
|
1219
|
+
recordType: "expectations",
|
|
1220
|
+
id: expectation._id,
|
|
1221
|
+
relationship: "derives_from"
|
|
1222
|
+
});
|
|
1223
|
+
assert(
|
|
1224
|
+
expectationStatementLinks.outbound.some((entry) => entry.record._id === statement._id),
|
|
1225
|
+
"Hosted list_linked_records did not return the linked Statement."
|
|
1226
|
+
);
|
|
1227
|
+
smokeStep("expectation and requirement checks ok");
|
|
1228
|
+
|
|
1229
|
+
await expectToolError(
|
|
1230
|
+
"create_estimate",
|
|
1231
|
+
{
|
|
1232
|
+
title: `DX Complete hosted MCP targetless estimate ${runId}`,
|
|
1233
|
+
lineItems: [
|
|
1234
|
+
{
|
|
1235
|
+
label: "Targetless line item",
|
|
1236
|
+
timing: "one_time",
|
|
1237
|
+
currency: "USD",
|
|
1238
|
+
amount: { kind: "single", value: 100 }
|
|
1239
|
+
}
|
|
1240
|
+
]
|
|
1241
|
+
},
|
|
1242
|
+
"requires at least one requirementId or expectationId"
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
await expectToolError(
|
|
1246
|
+
"create_estimate",
|
|
1247
|
+
{
|
|
1248
|
+
title: `DX Complete hosted MCP direction estimate ${runId}`,
|
|
1249
|
+
requirementIds: [requirement._id],
|
|
1250
|
+
lineItems: [
|
|
1251
|
+
{
|
|
1252
|
+
label: "Direction should be rejected",
|
|
1253
|
+
direction: "cost",
|
|
1254
|
+
timing: "one_time",
|
|
1255
|
+
currency: "USD",
|
|
1256
|
+
amount: { kind: "single", value: 100 }
|
|
1257
|
+
}
|
|
1258
|
+
]
|
|
1259
|
+
},
|
|
1260
|
+
"direction"
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
await expectToolError(
|
|
1264
|
+
"create_estimate",
|
|
1265
|
+
{
|
|
1266
|
+
title: `DX Complete hosted MCP recurring estimate without period ${runId}`,
|
|
1267
|
+
requirementIds: [requirement._id],
|
|
1268
|
+
lineItems: [
|
|
1269
|
+
{
|
|
1270
|
+
label: "Missing period",
|
|
1271
|
+
timing: "recurring",
|
|
1272
|
+
currency: "USD",
|
|
1273
|
+
amount: { kind: "single", value: 100 }
|
|
1274
|
+
}
|
|
1275
|
+
]
|
|
1276
|
+
},
|
|
1277
|
+
"requires period"
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
await expectToolError(
|
|
1281
|
+
"create_estimate",
|
|
1282
|
+
{
|
|
1283
|
+
title: `DX Complete hosted MCP invalid range estimate ${runId}`,
|
|
1284
|
+
requirementIds: [requirement._id],
|
|
1285
|
+
lineItems: [
|
|
1286
|
+
{
|
|
1287
|
+
label: "Invalid range",
|
|
1288
|
+
timing: "one_time",
|
|
1289
|
+
currency: "USD",
|
|
1290
|
+
amount: { kind: "range", min: 300, expected: 200, max: 100 }
|
|
1291
|
+
}
|
|
1292
|
+
]
|
|
1293
|
+
},
|
|
1294
|
+
"min <= expected <= max"
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
await expectToolError(
|
|
1298
|
+
"create_estimate",
|
|
1299
|
+
{
|
|
1300
|
+
title: `DX Complete hosted MCP direct rollup estimate ${runId}`,
|
|
1301
|
+
requirementIds: [requirement._id],
|
|
1302
|
+
lineItems: [
|
|
1303
|
+
{
|
|
1304
|
+
label: "Direct rollup guard",
|
|
1305
|
+
timing: "one_time",
|
|
1306
|
+
currency: "USD",
|
|
1307
|
+
amount: { kind: "single", value: 100 }
|
|
1308
|
+
}
|
|
1309
|
+
],
|
|
1310
|
+
fields: { rollup: {} }
|
|
1311
|
+
},
|
|
1312
|
+
"rollup"
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
const estimate = await callJsonTool("create_estimate", {
|
|
1316
|
+
title: `DX Complete hosted MCP smoke estimate ${runId}`,
|
|
1317
|
+
summary: "Estimate used by the hosted MCP Weigh smoke test.",
|
|
1318
|
+
requirementIds: [requirement._id],
|
|
1319
|
+
expectationIds: [expectation._id],
|
|
1320
|
+
lineItems: [
|
|
1321
|
+
{
|
|
1322
|
+
label: "Build work",
|
|
1323
|
+
timing: "one_time",
|
|
1324
|
+
currency: "USD",
|
|
1325
|
+
amount: { kind: "range", min: 1000, expected: 1500, max: 2000 }
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
label: "Run support",
|
|
1329
|
+
timing: "recurring",
|
|
1330
|
+
period: "monthly",
|
|
1331
|
+
currency: "USD",
|
|
1332
|
+
amount: { kind: "single", value: 100 }
|
|
1333
|
+
}
|
|
1334
|
+
]
|
|
1335
|
+
});
|
|
1336
|
+
createdEstimateId = estimate._id;
|
|
1337
|
+
assert(estimate.recordType === "estimates", "Hosted create_estimate created the wrong record type.");
|
|
1338
|
+
assert(estimate.workspaceId === workspaceConfig.workspaceId, "Hosted Estimate was not workspace-scoped.");
|
|
1339
|
+
assert(Array.isArray(estimate.fields.lineItems) && estimate.fields.lineItems.length === 2, "Hosted Estimate did not store line items.");
|
|
1340
|
+
assert(
|
|
1341
|
+
estimate.fields.lineItems.every((lineItem) => typeof lineItem.id === "string" && lineItem.id.length > 0),
|
|
1342
|
+
"Hosted Estimate did not generate line item IDs."
|
|
1343
|
+
);
|
|
1344
|
+
assert(
|
|
1345
|
+
estimate.fields.lineItems.every((lineItem) => lineItem.direction === undefined),
|
|
1346
|
+
"Hosted Estimate should not store direction on line items."
|
|
1347
|
+
);
|
|
1348
|
+
assert(
|
|
1349
|
+
estimate.links.some((link) => link.toType === "requirements" && link.toId === requirement._id && link.relationship === "estimates") &&
|
|
1350
|
+
estimate.links.some((link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "estimates"),
|
|
1351
|
+
"Hosted create_estimate did not create estimates links to scoped records."
|
|
1352
|
+
);
|
|
1353
|
+
assertEstimateAmount(
|
|
1354
|
+
estimate.fields.rollup?.currencies?.USD?.one_time,
|
|
1355
|
+
{ min: 1000, expected: 1500, max: 2000 },
|
|
1356
|
+
"Hosted Estimate roll-up did not total one-time USD costs."
|
|
1357
|
+
);
|
|
1358
|
+
assertEstimateAmount(
|
|
1359
|
+
estimate.fields.rollup?.currencies?.USD?.recurring?.monthly,
|
|
1360
|
+
{ min: 100, expected: 100, max: 100 },
|
|
1361
|
+
"Hosted Estimate roll-up did not total recurring monthly USD costs."
|
|
1362
|
+
);
|
|
1363
|
+
assert(
|
|
1364
|
+
estimate.fields.rollup?.currencies?.USD?.cost === undefined &&
|
|
1365
|
+
estimate.fields.rollup?.currencies?.USD?.benefit === undefined,
|
|
1366
|
+
"Hosted Estimate roll-up should be cost-only, not direction-split."
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
const revisedEstimateLineItems = estimate.fields.lineItems.map((lineItem) =>
|
|
1370
|
+
lineItem.label === "Build work"
|
|
1371
|
+
? { ...lineItem, amount: { kind: "range", min: 1200, expected: 1700, max: 2200 } }
|
|
1372
|
+
: lineItem
|
|
1373
|
+
);
|
|
1374
|
+
const updatedEstimate = await callJsonTool("update_estimate", {
|
|
1375
|
+
id: estimate._id,
|
|
1376
|
+
lineItems: revisedEstimateLineItems,
|
|
1377
|
+
revisionNote: "Smoke test estimate line-item revision."
|
|
1378
|
+
});
|
|
1379
|
+
assertEstimateAmount(
|
|
1380
|
+
updatedEstimate.fields.rollup?.currencies?.USD?.one_time,
|
|
1381
|
+
{ min: 1200, expected: 1700, max: 2200 },
|
|
1382
|
+
"Hosted update_estimate did not recompute one-time USD costs."
|
|
1383
|
+
);
|
|
1384
|
+
const estimateVersionHistory = updatedEstimate.fields.versionHistory ?? [];
|
|
1385
|
+
assert(estimateVersionHistory.length === 1, "Hosted update_estimate did not append version history.");
|
|
1386
|
+
assert(
|
|
1387
|
+
estimateVersionHistory[0].changedFields.includes("fields.lineItems") &&
|
|
1388
|
+
estimateVersionHistory[0].changedFields.includes("fields.rollup") &&
|
|
1389
|
+
estimateVersionHistory[0].revisionNote === "Smoke test estimate line-item revision.",
|
|
1390
|
+
"Hosted Estimate version history did not record changed fields and revision note."
|
|
1391
|
+
);
|
|
1392
|
+
assert(
|
|
1393
|
+
estimateVersionHistory[0].previousSnapshot.fields.rollup?.currencies?.USD?.one_time?.expected === 1500 &&
|
|
1394
|
+
estimateVersionHistory[0].nextSnapshot.fields.rollup?.currencies?.USD?.one_time?.expected === 1700,
|
|
1395
|
+
"Hosted Estimate version history did not preserve roll-up before/after snapshots."
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
const noOpEstimate = await callJsonTool("update_estimate", {
|
|
1399
|
+
id: estimate._id,
|
|
1400
|
+
lineItems: updatedEstimate.fields.lineItems,
|
|
1401
|
+
revisionNote: "No-op estimate update should not append history."
|
|
1402
|
+
});
|
|
1403
|
+
assert(
|
|
1404
|
+
noOpEstimate.fields.versionHistory?.length === 1,
|
|
1405
|
+
"Hosted no-op update_estimate should not append version history."
|
|
1406
|
+
);
|
|
1407
|
+
|
|
1408
|
+
await expectToolError(
|
|
1409
|
+
"create_record",
|
|
1410
|
+
{
|
|
1411
|
+
recordType: "estimates",
|
|
1412
|
+
title: `DX Complete hosted MCP direct line items estimate ${runId}`,
|
|
1413
|
+
fields: { lineItems: [] }
|
|
1414
|
+
},
|
|
1415
|
+
"create_estimate or update_estimate"
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
await expectToolError(
|
|
1419
|
+
"update_record",
|
|
1420
|
+
{
|
|
1421
|
+
recordType: "estimates",
|
|
1422
|
+
id: estimate._id,
|
|
1423
|
+
fields: { rollup: {} }
|
|
1424
|
+
},
|
|
1425
|
+
"update_estimate"
|
|
1426
|
+
);
|
|
1427
|
+
|
|
1428
|
+
await expectToolError(
|
|
1429
|
+
"update_record",
|
|
1430
|
+
{
|
|
1431
|
+
recordType: "estimates",
|
|
1432
|
+
id: estimate._id,
|
|
1433
|
+
unsetFields: ["versionHistory"]
|
|
1434
|
+
},
|
|
1435
|
+
"versionHistory"
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
await expectToolError(
|
|
1439
|
+
"create_benefits",
|
|
1440
|
+
{
|
|
1441
|
+
title: `DX Complete hosted MCP targetless benefits ${runId}`,
|
|
1442
|
+
benefitItems: [{ label: "Targetless benefit" }]
|
|
1443
|
+
},
|
|
1444
|
+
"requires at least one requirementId or expectationId"
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
await expectToolError(
|
|
1448
|
+
"create_benefits",
|
|
1449
|
+
{
|
|
1450
|
+
title: `DX Complete hosted MCP qualitative benefits with amount fields ${runId}`,
|
|
1451
|
+
requirementIds: [requirement._id],
|
|
1452
|
+
benefitItems: [{ label: "Qualitative with currency", currency: "USD" }]
|
|
1453
|
+
},
|
|
1454
|
+
"qualitative"
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
await expectToolError(
|
|
1458
|
+
"create_benefits",
|
|
1459
|
+
{
|
|
1460
|
+
title: `DX Complete hosted MCP recurring benefits without period ${runId}`,
|
|
1461
|
+
requirementIds: [requirement._id],
|
|
1462
|
+
benefitItems: [
|
|
1463
|
+
{
|
|
1464
|
+
label: "Missing period benefit",
|
|
1465
|
+
timing: "recurring",
|
|
1466
|
+
currency: "USD",
|
|
1467
|
+
amount: { kind: "single", value: 100 }
|
|
1468
|
+
}
|
|
1469
|
+
]
|
|
1470
|
+
},
|
|
1471
|
+
"requires period"
|
|
1472
|
+
);
|
|
1473
|
+
|
|
1474
|
+
const benefits = await callJsonTool("create_benefits", {
|
|
1475
|
+
title: `DX Complete hosted MCP smoke benefits ${runId}`,
|
|
1476
|
+
summary: "Benefits used by the hosted MCP Weigh smoke test.",
|
|
1477
|
+
requirementIds: [requirement._id],
|
|
1478
|
+
expectationIds: [expectation._id],
|
|
1479
|
+
benefitItems: [
|
|
1480
|
+
{
|
|
1481
|
+
label: "User time saved",
|
|
1482
|
+
timing: "recurring",
|
|
1483
|
+
period: "monthly",
|
|
1484
|
+
currency: "USD",
|
|
1485
|
+
amount: { kind: "range", min: 200, expected: 300, max: 400 }
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
label: "Clearer decisions"
|
|
1489
|
+
}
|
|
1490
|
+
]
|
|
1491
|
+
});
|
|
1492
|
+
createdBenefitsId = benefits._id;
|
|
1493
|
+
assert(benefits.recordType === "benefits", "Hosted create_benefits created the wrong record type.");
|
|
1494
|
+
assert(benefits.workspaceId === workspaceConfig.workspaceId, "Hosted Benefits was not workspace-scoped.");
|
|
1495
|
+
assert(Array.isArray(benefits.fields.benefitItems) && benefits.fields.benefitItems.length === 2, "Hosted Benefits did not store benefit items.");
|
|
1496
|
+
assert(
|
|
1497
|
+
benefits.fields.benefitItems.every((benefitItem) => typeof benefitItem.id === "string" && benefitItem.id.length > 0),
|
|
1498
|
+
"Hosted Benefits did not generate benefit item IDs."
|
|
1499
|
+
);
|
|
1500
|
+
assert(
|
|
1501
|
+
benefits.fields.benefitItems.some((benefitItem) => benefitItem.label === "Clearer decisions" && benefitItem.amount === undefined),
|
|
1502
|
+
"Hosted Benefits did not preserve qualitative benefit items."
|
|
1503
|
+
);
|
|
1504
|
+
assert(
|
|
1505
|
+
benefits.links.some((link) => link.toType === "requirements" && link.toId === requirement._id && link.relationship === "benefits") &&
|
|
1506
|
+
benefits.links.some((link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "benefits"),
|
|
1507
|
+
"Hosted create_benefits did not create benefits links to scoped records."
|
|
1508
|
+
);
|
|
1509
|
+
assertEstimateAmount(
|
|
1510
|
+
benefits.fields.rollup?.currencies?.USD?.recurring?.monthly,
|
|
1511
|
+
{ min: 200, expected: 300, max: 400 },
|
|
1512
|
+
"Hosted Benefits roll-up did not total quantified recurring monthly USD benefits."
|
|
1513
|
+
);
|
|
1514
|
+
assert(
|
|
1515
|
+
!JSON.stringify(benefits.fields.rollup ?? {}).includes("Clearer decisions"),
|
|
1516
|
+
"Hosted Benefits roll-up should not sum qualitative benefit items."
|
|
1517
|
+
);
|
|
1518
|
+
|
|
1519
|
+
const revisedBenefitItems = benefits.fields.benefitItems.map((benefitItem) =>
|
|
1520
|
+
benefitItem.label === "User time saved"
|
|
1521
|
+
? { ...benefitItem, amount: { kind: "range", min: 250, expected: 350, max: 450 } }
|
|
1522
|
+
: benefitItem
|
|
1523
|
+
);
|
|
1524
|
+
const updatedBenefits = await callJsonTool("update_benefits", {
|
|
1525
|
+
id: benefits._id,
|
|
1526
|
+
benefitItems: revisedBenefitItems,
|
|
1527
|
+
revisionNote: "Smoke test benefits item revision."
|
|
1528
|
+
});
|
|
1529
|
+
assertEstimateAmount(
|
|
1530
|
+
updatedBenefits.fields.rollup?.currencies?.USD?.recurring?.monthly,
|
|
1531
|
+
{ min: 250, expected: 350, max: 450 },
|
|
1532
|
+
"Hosted update_benefits did not recompute quantified benefits."
|
|
1533
|
+
);
|
|
1534
|
+
const benefitsVersionHistory = updatedBenefits.fields.versionHistory ?? [];
|
|
1535
|
+
assert(benefitsVersionHistory.length === 1, "Hosted update_benefits did not append version history.");
|
|
1536
|
+
assert(
|
|
1537
|
+
benefitsVersionHistory[0].changedFields.includes("fields.benefitItems") &&
|
|
1538
|
+
benefitsVersionHistory[0].changedFields.includes("fields.rollup") &&
|
|
1539
|
+
benefitsVersionHistory[0].revisionNote === "Smoke test benefits item revision.",
|
|
1540
|
+
"Hosted Benefits version history did not record changed fields and revision note."
|
|
1541
|
+
);
|
|
1542
|
+
const noOpBenefits = await callJsonTool("update_benefits", {
|
|
1543
|
+
id: benefits._id,
|
|
1544
|
+
benefitItems: updatedBenefits.fields.benefitItems,
|
|
1545
|
+
revisionNote: "No-op benefits update should not append history."
|
|
1546
|
+
});
|
|
1547
|
+
assert(
|
|
1548
|
+
noOpBenefits.fields.versionHistory?.length === 1,
|
|
1549
|
+
"Hosted no-op update_benefits should not append version history."
|
|
1550
|
+
);
|
|
1551
|
+
|
|
1552
|
+
await expectToolError(
|
|
1553
|
+
"create_record",
|
|
1554
|
+
{
|
|
1555
|
+
recordType: "benefits",
|
|
1556
|
+
title: `DX Complete hosted MCP direct benefit items ${runId}`,
|
|
1557
|
+
fields: { benefitItems: [] }
|
|
1558
|
+
},
|
|
1559
|
+
"create_benefits or update_benefits"
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
await expectToolError(
|
|
1563
|
+
"update_record",
|
|
1564
|
+
{
|
|
1565
|
+
recordType: "benefits",
|
|
1566
|
+
id: benefits._id,
|
|
1567
|
+
fields: { rollup: {} }
|
|
1568
|
+
},
|
|
1569
|
+
"update_benefits"
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
await expectToolError(
|
|
1573
|
+
"update_record",
|
|
1574
|
+
{
|
|
1575
|
+
recordType: "benefits",
|
|
1576
|
+
id: benefits._id,
|
|
1577
|
+
unsetFields: ["versionHistory"]
|
|
1578
|
+
},
|
|
1579
|
+
"versionHistory"
|
|
1580
|
+
);
|
|
1581
|
+
smokeStep("estimate and benefits checks ok");
|
|
1582
|
+
|
|
1583
|
+
const deferral = await callJsonTool("create_deferral", {
|
|
1584
|
+
title: `DX Complete hosted MCP smoke deferral ${runId}`,
|
|
1585
|
+
reason: "The smoke run needs an explicit unmet condition before committing.",
|
|
1586
|
+
conditions: ["Confirm the smoke condition can be addressed."],
|
|
1587
|
+
requirementIds: [requirement._id],
|
|
1588
|
+
expectationIds: [expectation._id]
|
|
1589
|
+
});
|
|
1590
|
+
createdDeferralId = deferral._id;
|
|
1591
|
+
assert(deferral.recordType === "deferrals", "Hosted create_deferral created the wrong record type.");
|
|
1592
|
+
assert(deferral.workspaceId === workspaceConfig.workspaceId, "Hosted Deferral was not workspace-scoped.");
|
|
1593
|
+
assert(deferral.fields.status === "open", "Hosted Deferral did not default to open status.");
|
|
1594
|
+
assert(deferral.fields.reason?.includes("unmet condition"), "Hosted Deferral did not store reason.");
|
|
1595
|
+
assert(
|
|
1596
|
+
Array.isArray(deferral.fields.conditions) &&
|
|
1597
|
+
deferral.fields.conditions.length === 1 &&
|
|
1598
|
+
deferral.fields.conditions[0].state === "open",
|
|
1599
|
+
"Hosted Deferral did not create open conditions."
|
|
1600
|
+
);
|
|
1601
|
+
assert(
|
|
1602
|
+
deferral.links.some((link) => link.toType === "requirements" && link.toId === requirement._id && link.relationship === "defers") &&
|
|
1603
|
+
deferral.links.some((link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "defers"),
|
|
1604
|
+
"Hosted create_deferral did not create defers links."
|
|
1605
|
+
);
|
|
1606
|
+
const conditionId = deferral.fields.conditions[0].id;
|
|
1607
|
+
|
|
1608
|
+
const deferralWithAddressedCondition = await callJsonTool("append_deferral_event", {
|
|
1609
|
+
deferralId: deferral._id,
|
|
1610
|
+
eventType: "condition_addressed",
|
|
1611
|
+
conditionId,
|
|
1612
|
+
summary: "Smoke condition addressed."
|
|
1613
|
+
});
|
|
1614
|
+
assert(
|
|
1615
|
+
deferralWithAddressedCondition.fields.conditions[0].state === "addressed" &&
|
|
1616
|
+
deferralWithAddressedCondition.fields.conditionEvents?.at(-1)?.eventType === "condition_addressed",
|
|
1617
|
+
"Hosted append_deferral_event did not address the condition and append history."
|
|
1618
|
+
);
|
|
1619
|
+
|
|
1620
|
+
const deferralWithReopenedCondition = await callJsonTool("append_deferral_event", {
|
|
1621
|
+
deferralId: deferral._id,
|
|
1622
|
+
eventType: "condition_reopened",
|
|
1623
|
+
conditionId,
|
|
1624
|
+
summary: "Smoke condition reopened."
|
|
1625
|
+
});
|
|
1626
|
+
assert(
|
|
1627
|
+
deferralWithReopenedCondition.fields.conditions[0].state === "open" &&
|
|
1628
|
+
deferralWithReopenedCondition.fields.conditionEvents?.at(-1)?.eventType === "condition_reopened",
|
|
1629
|
+
"Hosted append_deferral_event did not reopen the condition and append history."
|
|
1630
|
+
);
|
|
1631
|
+
smokeStep("deferral condition checks ok");
|
|
1632
|
+
|
|
1633
|
+
const deferralWithConditionNote = await callJsonTool("append_deferral_event", {
|
|
1634
|
+
deferralId: deferral._id,
|
|
1635
|
+
eventType: "condition_note_added",
|
|
1636
|
+
conditionId,
|
|
1637
|
+
note: "Smoke condition note."
|
|
1638
|
+
});
|
|
1639
|
+
assert(
|
|
1640
|
+
deferralWithConditionNote.fields.conditionEvents?.at(-1)?.note === "Smoke condition note.",
|
|
1641
|
+
"Hosted append_deferral_event did not append the condition note."
|
|
1642
|
+
);
|
|
1643
|
+
|
|
1644
|
+
await expectToolError(
|
|
1645
|
+
"append_deferral_event",
|
|
1646
|
+
{
|
|
1647
|
+
deferralId: deferral._id,
|
|
1648
|
+
eventType: "condition_addressed",
|
|
1649
|
+
summary: "Missing conditionId should fail."
|
|
1650
|
+
},
|
|
1651
|
+
"condition_addressed requires conditionId"
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1654
|
+
const commitment = await callJsonTool("create_commitment", {
|
|
1655
|
+
title: `DX Complete hosted MCP smoke commitment ${runId}`,
|
|
1656
|
+
commitmentStatement: "The smoke run commits the requirement into Build.",
|
|
1657
|
+
requirementIds: [requirement._id],
|
|
1658
|
+
expectationIds: [expectation._id],
|
|
1659
|
+
reservationNotes: ["Smoke reservation kept visible."],
|
|
1660
|
+
deferralId: deferral._id
|
|
1661
|
+
});
|
|
1662
|
+
createdCommitmentId = commitment._id;
|
|
1663
|
+
assert(commitment.recordType === "commitments", "Hosted create_commitment created the wrong record type.");
|
|
1664
|
+
assert(commitment.workspaceId === workspaceConfig.workspaceId, "Hosted Commitment was not workspace-scoped.");
|
|
1665
|
+
assert(commitment.fields.commitmentStatement?.includes("commits the requirement"), "Hosted Commitment did not store statement.");
|
|
1666
|
+
assert(commitment.fields.reservations?.[0]?.note === "Smoke reservation kept visible.", "Hosted Commitment did not store reservations.");
|
|
1667
|
+
assert(
|
|
1668
|
+
commitment.links.some((link) => link.toType === "requirements" && link.toId === requirement._id && link.relationship === "commits") &&
|
|
1669
|
+
commitment.links.some((link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "commits") &&
|
|
1670
|
+
commitment.links.some((link) => link.toType === "deferrals" && link.toId === deferral._id && link.relationship === "resolves_deferral"),
|
|
1671
|
+
"Hosted create_commitment did not create expected links."
|
|
1672
|
+
);
|
|
1673
|
+
|
|
1674
|
+
const commitmentWithEstimateInput = await callJsonTool("link_records", {
|
|
1675
|
+
fromType: "commitments",
|
|
1676
|
+
fromId: commitment._id,
|
|
1677
|
+
toType: "estimates",
|
|
1678
|
+
toId: estimate._id,
|
|
1679
|
+
relationship: "informed_by"
|
|
1680
|
+
});
|
|
1681
|
+
assert(
|
|
1682
|
+
commitmentWithEstimateInput.links.some(
|
|
1683
|
+
(link) => link.toType === "estimates" && link.toId === estimate._id && link.relationship === "informed_by"
|
|
1684
|
+
),
|
|
1685
|
+
"Hosted link_records did not link Commitment to Estimate as informed_by."
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
const commitmentWithBenefitsInput = await callJsonTool("link_records", {
|
|
1689
|
+
fromType: "commitments",
|
|
1690
|
+
fromId: commitment._id,
|
|
1691
|
+
toType: "benefits",
|
|
1692
|
+
toId: benefits._id,
|
|
1693
|
+
relationship: "informed_by"
|
|
1694
|
+
});
|
|
1695
|
+
assert(
|
|
1696
|
+
commitmentWithBenefitsInput.links.some(
|
|
1697
|
+
(link) => link.toType === "benefits" && link.toId === benefits._id && link.relationship === "informed_by"
|
|
1698
|
+
),
|
|
1699
|
+
"Hosted link_records did not link Commitment to Benefits as informed_by."
|
|
1700
|
+
);
|
|
1701
|
+
smokeStep("commitment checks ok");
|
|
1702
|
+
|
|
1703
|
+
const resolvedDeferral = await callJsonTool("append_deferral_event", {
|
|
1704
|
+
deferralId: deferral._id,
|
|
1705
|
+
eventType: "deferral_resolved",
|
|
1706
|
+
commitmentId: commitment._id,
|
|
1707
|
+
summary: "Smoke deferral resolved into commitment."
|
|
1708
|
+
});
|
|
1709
|
+
assert(
|
|
1710
|
+
resolvedDeferral.fields.status === "resolved" &&
|
|
1711
|
+
resolvedDeferral.fields.conditionEvents?.at(-1)?.commitmentId === commitment._id,
|
|
1712
|
+
"Hosted append_deferral_event did not resolve the Deferral."
|
|
1713
|
+
);
|
|
1714
|
+
const commitmentDeferralLinks = await callJsonTool("list_linked_records", {
|
|
1715
|
+
recordType: "commitments",
|
|
1716
|
+
id: commitment._id,
|
|
1717
|
+
relationship: "resolves_deferral"
|
|
1718
|
+
});
|
|
1719
|
+
assert(
|
|
1720
|
+
commitmentDeferralLinks.outbound.some((entry) => entry.record._id === deferral._id),
|
|
1721
|
+
"Hosted Deferral resolution did not link Commitment back to Deferral."
|
|
1722
|
+
);
|
|
1723
|
+
|
|
1724
|
+
await expectToolError(
|
|
1725
|
+
"create_commitment",
|
|
1726
|
+
{
|
|
1727
|
+
title: `DX Complete hosted MCP targetless commitment ${runId}`,
|
|
1728
|
+
commitmentStatement: "This should fail."
|
|
1729
|
+
},
|
|
1730
|
+
"requires at least one requirementId or expectationId"
|
|
1731
|
+
);
|
|
1732
|
+
await expectToolError(
|
|
1733
|
+
"create_record",
|
|
1734
|
+
{
|
|
1735
|
+
recordType: "commitments",
|
|
1736
|
+
title: `DX Complete hosted MCP direct commitment ${runId}`,
|
|
1737
|
+
fields: { commitmentStatement: "This should fail." }
|
|
1738
|
+
},
|
|
1739
|
+
"create_commitment"
|
|
1740
|
+
);
|
|
1741
|
+
await expectToolError(
|
|
1742
|
+
"create_record",
|
|
1743
|
+
{
|
|
1744
|
+
recordType: "deferrals",
|
|
1745
|
+
title: `DX Complete hosted MCP direct deferral ${runId}`,
|
|
1746
|
+
fields: { conditions: [] }
|
|
1747
|
+
},
|
|
1748
|
+
"create_deferral"
|
|
1749
|
+
);
|
|
1750
|
+
await expectToolError(
|
|
1751
|
+
"update_record",
|
|
1752
|
+
{
|
|
1753
|
+
recordType: "deferrals",
|
|
1754
|
+
id: deferral._id,
|
|
1755
|
+
unsetFields: ["conditionEvents"]
|
|
1756
|
+
},
|
|
1757
|
+
"append_deferral_event"
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
const change = await callJsonTool("create_change", {
|
|
1761
|
+
title: `DX Complete hosted MCP smoke change ${runId}`,
|
|
1762
|
+
summary: "Change used by the hosted MCP smoke test.",
|
|
1763
|
+
changePlan: "Record a harmless smoke-test service change.",
|
|
1764
|
+
executionSteps: ["Create the Change record.", "Append the smoke-test events."],
|
|
1765
|
+
rollbackPlan: "Archive the smoke-test records during cleanup.",
|
|
1766
|
+
riskImpact: "No service impact; test data only.",
|
|
1767
|
+
plannedFor: new Date().toISOString(),
|
|
1768
|
+
requirementId: requirement._id
|
|
1769
|
+
});
|
|
1770
|
+
createdChangeId = change._id;
|
|
1771
|
+
assert(change.recordType === "changes", "Hosted create_change created the wrong record type.");
|
|
1772
|
+
assert(change.workspaceId === workspaceConfig.workspaceId, "Hosted Change was not workspace-scoped.");
|
|
1773
|
+
assert(change.fields.changePlan === "Record a harmless smoke-test service change.", "Hosted Change did not store changePlan.");
|
|
1774
|
+
assert(Array.isArray(change.fields.executionSteps) && change.fields.executionSteps.length === 2, "Hosted Change did not store executionSteps.");
|
|
1775
|
+
assert(change.fields.rollbackPlan?.includes("Archive"), "Hosted Change did not store rollbackPlan.");
|
|
1776
|
+
assert(change.fields.riskImpact?.includes("No service impact"), "Hosted Change did not store riskImpact.");
|
|
1777
|
+
assert(
|
|
1778
|
+
change.links.some((link) => link.toType === "requirements" && link.toId === requirement._id && link.relationship === "for_requirement"),
|
|
1779
|
+
"Hosted create_change did not link to the requirement."
|
|
1780
|
+
);
|
|
1781
|
+
|
|
1782
|
+
const changeWithNotice = await callJsonTool("append_change_event", {
|
|
1783
|
+
changeId: change._id,
|
|
1784
|
+
eventType: "notice_given",
|
|
1785
|
+
notice: "Smoke test notice.",
|
|
1786
|
+
noticeTo: ["Operator", "Engineer"],
|
|
1787
|
+
effectiveAt: new Date().toISOString()
|
|
1788
|
+
});
|
|
1789
|
+
const noticeEvent = changeWithNotice.fields.events?.at(-1);
|
|
1790
|
+
assert(noticeEvent?.eventType === "notice_given", "Hosted append_change_event did not append notice event.");
|
|
1791
|
+
assert(noticeEvent?.createdBy === actor.actorId, "Hosted Change event was not attributed to the token actor.");
|
|
1792
|
+
assert(noticeEvent?.notice === "Smoke test notice.", "Hosted Change notice event content was not stored.");
|
|
1793
|
+
|
|
1794
|
+
const changeWithVeto = await callJsonTool("append_change_event", {
|
|
1795
|
+
changeId: change._id,
|
|
1796
|
+
eventType: "veto_recorded",
|
|
1797
|
+
vetoByRole: "Engineer",
|
|
1798
|
+
reason: "Smoke test veto."
|
|
1799
|
+
});
|
|
1800
|
+
assert(
|
|
1801
|
+
changeWithVeto.fields.events?.at(-1)?.vetoByRole === "Engineer",
|
|
1802
|
+
"Hosted Change veto event did not store the veto role."
|
|
1803
|
+
);
|
|
1804
|
+
|
|
1805
|
+
await expectToolError(
|
|
1806
|
+
"append_change_event",
|
|
1807
|
+
{
|
|
1808
|
+
changeId: change._id,
|
|
1809
|
+
eventType: "emergency_declared",
|
|
1810
|
+
importance: "Smoke test importance without immediacy."
|
|
1811
|
+
},
|
|
1812
|
+
"emergency_declared requires immediacy"
|
|
1813
|
+
);
|
|
1814
|
+
|
|
1815
|
+
await expectToolError(
|
|
1816
|
+
"append_change_event",
|
|
1817
|
+
{
|
|
1818
|
+
changeId: change._id,
|
|
1819
|
+
eventType: "veto_recorded",
|
|
1820
|
+
vetoByRole: "Operator",
|
|
1821
|
+
reason: "This should fail."
|
|
1822
|
+
},
|
|
1823
|
+
"Invalid option"
|
|
1824
|
+
);
|
|
1825
|
+
|
|
1826
|
+
await expectToolError(
|
|
1827
|
+
"create_record",
|
|
1828
|
+
{
|
|
1829
|
+
recordType: "changes",
|
|
1830
|
+
title: `DX Complete hosted MCP direct events change ${runId}`,
|
|
1831
|
+
fields: { events: [] }
|
|
1832
|
+
},
|
|
1833
|
+
"append_change_event"
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
await expectToolError(
|
|
1837
|
+
"update_record",
|
|
1838
|
+
{
|
|
1839
|
+
recordType: "changes",
|
|
1840
|
+
id: change._id,
|
|
1841
|
+
unsetFields: ["events"]
|
|
1842
|
+
},
|
|
1843
|
+
"append_change_event"
|
|
1844
|
+
);
|
|
1845
|
+
|
|
1846
|
+
const changedLinks = await callJsonTool("list_linked_records", {
|
|
1847
|
+
recordType: "changes",
|
|
1848
|
+
id: change._id,
|
|
1849
|
+
relationship: "for_requirement"
|
|
1850
|
+
});
|
|
1851
|
+
assert(
|
|
1852
|
+
changedLinks.outbound.some((entry) => entry.record._id === requirement._id),
|
|
1853
|
+
"Hosted list_linked_records did not return the linked requirement for Change."
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
const referencedRequirement = await callJsonTool("link_records", {
|
|
1857
|
+
fromType: "requirements",
|
|
1858
|
+
fromId: requirement._id,
|
|
1859
|
+
toType: "expectations",
|
|
1860
|
+
toId: expectation._id,
|
|
1861
|
+
relationship: "references"
|
|
1862
|
+
});
|
|
1863
|
+
assert(
|
|
1864
|
+
referencedRequirement.links.some(
|
|
1865
|
+
(link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "references"
|
|
1866
|
+
),
|
|
1867
|
+
"Hosted link_records did not create a removable references link."
|
|
1868
|
+
);
|
|
1869
|
+
const unlinkedRequirement = await callJsonTool("unlink_records", {
|
|
1870
|
+
fromType: "requirements",
|
|
1871
|
+
fromId: requirement._id,
|
|
1872
|
+
toType: "expectations",
|
|
1873
|
+
toId: expectation._id,
|
|
1874
|
+
relationship: "references"
|
|
1875
|
+
});
|
|
1876
|
+
assert(
|
|
1877
|
+
!unlinkedRequirement.links.some(
|
|
1878
|
+
(link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "references"
|
|
1879
|
+
) &&
|
|
1880
|
+
unlinkedRequirement.links.some(
|
|
1881
|
+
(link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "satisfies"
|
|
1882
|
+
),
|
|
1883
|
+
"Hosted unlink_records did not remove only the selected relationship."
|
|
1884
|
+
);
|
|
1885
|
+
const idempotentRequirementUnlink = await callJsonTool("unlink_records", {
|
|
1886
|
+
fromType: "requirements",
|
|
1887
|
+
fromId: requirement._id,
|
|
1888
|
+
toType: "expectations",
|
|
1889
|
+
toId: expectation._id,
|
|
1890
|
+
relationship: "references"
|
|
1891
|
+
});
|
|
1892
|
+
assert(
|
|
1893
|
+
idempotentRequirementUnlink.links.length === unlinkedRequirement.links.length,
|
|
1894
|
+
"Hosted unlink_records should be idempotent when a link is already absent."
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
await expectToolError(
|
|
1898
|
+
"create_decision",
|
|
1899
|
+
{
|
|
1900
|
+
title: `DX Complete hosted MCP direct decision inputs ${runId}`,
|
|
1901
|
+
matter: "Should direct decision inputs be accepted?",
|
|
1902
|
+
fields: { informedBy: [expectation._id] }
|
|
1903
|
+
},
|
|
1904
|
+
"link_decision_input"
|
|
1905
|
+
);
|
|
1906
|
+
await expectToolError(
|
|
1907
|
+
"create_record",
|
|
1908
|
+
{
|
|
1909
|
+
recordType: "decisions",
|
|
1910
|
+
title: `DX Complete hosted MCP direct decision entries ${runId}`,
|
|
1911
|
+
fields: { entries: [] }
|
|
1912
|
+
},
|
|
1913
|
+
"append_decision_entry"
|
|
1914
|
+
);
|
|
1915
|
+
|
|
1916
|
+
const decision = await callJsonTool("create_decision", {
|
|
1917
|
+
title: `DX Complete hosted MCP smoke decision ${runId}`,
|
|
1918
|
+
matter: "Should the hosted smoke test link decision inputs?",
|
|
1919
|
+
initialEntries: [
|
|
1920
|
+
{
|
|
1921
|
+
entryType: "argument",
|
|
1922
|
+
body: "Expectation inputs show the desired outcome."
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
entryType: "argument",
|
|
1926
|
+
body: "Change inputs show run-side control evidence."
|
|
1927
|
+
}
|
|
1928
|
+
],
|
|
1929
|
+
initialDecision: {
|
|
1930
|
+
body: "Link decision inputs before archiving smoke records.",
|
|
1931
|
+
decidedBy: "Owner",
|
|
1932
|
+
rationale: "The smoke run needs to prove outbound and inbound informed_by traversal."
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
createdDecisionId = decision._id;
|
|
1936
|
+
assert(decision.recordType === "decisions", "Hosted create_decision created the wrong record type.");
|
|
1937
|
+
assert(
|
|
1938
|
+
decision.fields.currentDecision?.body === "Link decision inputs before archiving smoke records." &&
|
|
1939
|
+
decision.fields.entries?.length === 3,
|
|
1940
|
+
"Hosted create_decision did not seed ledger entries and current decision."
|
|
1941
|
+
);
|
|
1942
|
+
|
|
1943
|
+
await expectToolError(
|
|
1944
|
+
"append_decision_entry",
|
|
1945
|
+
{
|
|
1946
|
+
decisionId: decision._id,
|
|
1947
|
+
entryType: "argument",
|
|
1948
|
+
body: "Invalid argument entry.",
|
|
1949
|
+
rationale: "Arguments cannot carry decision-only rationale."
|
|
1950
|
+
},
|
|
1951
|
+
"rationale"
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
const supersededDecision = await callJsonTool("append_decision_entry", {
|
|
1955
|
+
decisionId: decision._id,
|
|
1956
|
+
entryType: "decision",
|
|
1957
|
+
body: "Link decision inputs and verify later decision entries take primacy.",
|
|
1958
|
+
decidedBy: "Owner"
|
|
1959
|
+
});
|
|
1960
|
+
assert(
|
|
1961
|
+
supersededDecision.fields.currentDecision?.body === "Link decision inputs and verify later decision entries take primacy." &&
|
|
1962
|
+
supersededDecision.fields.entries?.length === 4 &&
|
|
1963
|
+
supersededDecision.fields.entries.some((entry) => entry.body === "Link decision inputs before archiving smoke records."),
|
|
1964
|
+
"Hosted append_decision_entry did not derive current decision from the latest decision entry while preserving prior entries."
|
|
1965
|
+
);
|
|
1966
|
+
|
|
1967
|
+
await expectToolError(
|
|
1968
|
+
"update_record",
|
|
1969
|
+
{
|
|
1970
|
+
recordType: "decisions",
|
|
1971
|
+
id: decision._id,
|
|
1972
|
+
fields: { currentDecision: { body: "mutated" } }
|
|
1973
|
+
},
|
|
1974
|
+
"append_decision_entry"
|
|
1975
|
+
);
|
|
1976
|
+
await expectToolError(
|
|
1977
|
+
"update_record",
|
|
1978
|
+
{
|
|
1979
|
+
recordType: "decisions",
|
|
1980
|
+
id: decision._id,
|
|
1981
|
+
fields: { status: "decided" }
|
|
1982
|
+
},
|
|
1983
|
+
"append_decision_entry"
|
|
1984
|
+
);
|
|
1985
|
+
await expectToolError(
|
|
1986
|
+
"update_record",
|
|
1987
|
+
{
|
|
1988
|
+
recordType: "decisions",
|
|
1989
|
+
id: decision._id,
|
|
1990
|
+
unsetFields: ["currentDecision"]
|
|
1991
|
+
},
|
|
1992
|
+
"append_decision_entry"
|
|
1993
|
+
);
|
|
1994
|
+
|
|
1995
|
+
const decisionWithExpectationInput = await callJsonTool("link_decision_input", {
|
|
1996
|
+
decisionId: decision._id,
|
|
1997
|
+
inputRecordType: "expectations",
|
|
1998
|
+
inputId: expectation._id
|
|
1999
|
+
});
|
|
2000
|
+
assert(
|
|
2001
|
+
decisionWithExpectationInput.links.some(
|
|
2002
|
+
(link) =>
|
|
2003
|
+
link.toType === "expectations" &&
|
|
2004
|
+
link.toId === expectation._id &&
|
|
2005
|
+
link.relationship === "informed_by"
|
|
2006
|
+
),
|
|
2007
|
+
"Hosted link_decision_input did not link the Decision to the Expectation."
|
|
2008
|
+
);
|
|
2009
|
+
|
|
2010
|
+
const decisionWithChangeInput = await callJsonTool("link_decision_input", {
|
|
2011
|
+
decisionId: decision._id,
|
|
2012
|
+
inputRecordType: "changes",
|
|
2013
|
+
inputId: change._id
|
|
2014
|
+
});
|
|
2015
|
+
assert(
|
|
2016
|
+
decisionWithChangeInput.links.some(
|
|
2017
|
+
(link) =>
|
|
2018
|
+
link.toType === "changes" &&
|
|
2019
|
+
link.toId === change._id &&
|
|
2020
|
+
link.relationship === "informed_by"
|
|
2021
|
+
),
|
|
2022
|
+
"Hosted link_decision_input did not link the Decision to the Change."
|
|
2023
|
+
);
|
|
2024
|
+
|
|
2025
|
+
const decisionWithEstimateInput = await callJsonTool("link_decision_input", {
|
|
2026
|
+
decisionId: decision._id,
|
|
2027
|
+
inputRecordType: "estimates",
|
|
2028
|
+
inputId: estimate._id
|
|
2029
|
+
});
|
|
2030
|
+
assert(
|
|
2031
|
+
decisionWithEstimateInput.links.some(
|
|
2032
|
+
(link) =>
|
|
2033
|
+
link.toType === "estimates" &&
|
|
2034
|
+
link.toId === estimate._id &&
|
|
2035
|
+
link.relationship === "informed_by"
|
|
2036
|
+
),
|
|
2037
|
+
"Hosted link_decision_input did not link the Decision to the Estimate."
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
const decisionWithBenefitsInput = await callJsonTool("link_decision_input", {
|
|
2041
|
+
decisionId: decision._id,
|
|
2042
|
+
inputRecordType: "benefits",
|
|
2043
|
+
inputId: benefits._id
|
|
2044
|
+
});
|
|
2045
|
+
assert(
|
|
2046
|
+
decisionWithBenefitsInput.links.some(
|
|
2047
|
+
(link) =>
|
|
2048
|
+
link.toType === "benefits" &&
|
|
2049
|
+
link.toId === benefits._id &&
|
|
2050
|
+
link.relationship === "informed_by"
|
|
2051
|
+
),
|
|
2052
|
+
"Hosted link_decision_input did not link the Decision to Benefits."
|
|
2053
|
+
);
|
|
2054
|
+
smokeStep("decision input checks ok");
|
|
2055
|
+
|
|
2056
|
+
const repeatedDecisionInput = await callJsonTool("link_decision_input", {
|
|
2057
|
+
decisionId: decision._id,
|
|
2058
|
+
inputRecordType: "expectations",
|
|
2059
|
+
inputId: expectation._id
|
|
2060
|
+
});
|
|
2061
|
+
const expectationInputLinks = repeatedDecisionInput.links.filter(
|
|
2062
|
+
(link) =>
|
|
2063
|
+
link.toType === "expectations" &&
|
|
2064
|
+
link.toId === expectation._id &&
|
|
2065
|
+
link.relationship === "informed_by"
|
|
2066
|
+
);
|
|
2067
|
+
assert(expectationInputLinks.length === 1, "Hosted link_decision_input should not duplicate links.");
|
|
2068
|
+
|
|
2069
|
+
const decisionInputs = await callJsonTool("list_linked_records", {
|
|
2070
|
+
recordType: "decisions",
|
|
2071
|
+
id: decision._id,
|
|
2072
|
+
direction: "outbound",
|
|
2073
|
+
relationship: "informed_by"
|
|
2074
|
+
});
|
|
2075
|
+
assert(
|
|
2076
|
+
decisionInputs.outbound.some((entry) => entry.record._id === expectation._id) &&
|
|
2077
|
+
decisionInputs.outbound.some((entry) => entry.record._id === change._id) &&
|
|
2078
|
+
decisionInputs.outbound.some((entry) => entry.record._id === estimate._id),
|
|
2079
|
+
"Hosted list_linked_records did not return the Decision inputs."
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
const expectationInboundDecisionLinks = await callJsonTool("list_linked_records", {
|
|
2083
|
+
recordType: "expectations",
|
|
2084
|
+
id: expectation._id,
|
|
2085
|
+
direction: "inbound",
|
|
2086
|
+
relationship: "informed_by"
|
|
2087
|
+
});
|
|
2088
|
+
assert(
|
|
2089
|
+
expectationInboundDecisionLinks.inbound.some((entry) => entry.record._id === decision._id),
|
|
2090
|
+
"Hosted list_linked_records did not return inbound Decision input links."
|
|
2091
|
+
);
|
|
2092
|
+
|
|
2093
|
+
const decisionWithoutBenefitsInput = await callJsonTool("unlink_records", {
|
|
2094
|
+
fromType: "decisions",
|
|
2095
|
+
fromId: decision._id,
|
|
2096
|
+
toType: "benefits",
|
|
2097
|
+
toId: benefits._id,
|
|
2098
|
+
relationship: "informed_by"
|
|
2099
|
+
});
|
|
2100
|
+
assert(
|
|
2101
|
+
!decisionWithoutBenefitsInput.links.some(
|
|
2102
|
+
(link) => link.toType === "benefits" && link.toId === benefits._id && link.relationship === "informed_by"
|
|
2103
|
+
) &&
|
|
2104
|
+
decisionWithoutBenefitsInput.links.some(
|
|
2105
|
+
(link) => link.toType === "expectations" && link.toId === expectation._id && link.relationship === "informed_by"
|
|
2106
|
+
),
|
|
2107
|
+
"Hosted unlink_records did not remove the selected Decision input link while preserving other inputs."
|
|
2108
|
+
);
|
|
2109
|
+
const benefitsInboundDecisionLinks = await callJsonTool("list_linked_records", {
|
|
2110
|
+
recordType: "benefits",
|
|
2111
|
+
id: benefits._id,
|
|
2112
|
+
direction: "inbound",
|
|
2113
|
+
relationship: "informed_by"
|
|
2114
|
+
});
|
|
2115
|
+
assert(
|
|
2116
|
+
!benefitsInboundDecisionLinks.inbound.some((entry) => entry.record._id === decision._id),
|
|
2117
|
+
"Hosted list_linked_records still returned an inbound Decision input after unlink_records."
|
|
2118
|
+
);
|
|
2119
|
+
|
|
2120
|
+
await expectToolError(
|
|
2121
|
+
"link_decision_input",
|
|
2122
|
+
{
|
|
2123
|
+
decisionId: decision._id,
|
|
2124
|
+
inputRecordType: "tasks",
|
|
2125
|
+
inputId: "not-a-canonical-input"
|
|
2126
|
+
},
|
|
2127
|
+
"Invalid option"
|
|
2128
|
+
);
|
|
2129
|
+
|
|
2130
|
+
const archivedDecision = await callJsonTool("archive_record", {
|
|
2131
|
+
recordType: "decisions",
|
|
2132
|
+
id: decision._id
|
|
2133
|
+
});
|
|
2134
|
+
assert(archivedDecision.archivedAt, "Hosted archive_record did not archive the smoke Decision.");
|
|
2135
|
+
createdDecisionId = undefined;
|
|
2136
|
+
|
|
2137
|
+
const archivedChange = await callJsonTool("archive_record", {
|
|
2138
|
+
recordType: "changes",
|
|
2139
|
+
id: change._id
|
|
2140
|
+
});
|
|
2141
|
+
assert(archivedChange.archivedAt, "Hosted archive_record did not archive the smoke Change.");
|
|
2142
|
+
createdChangeId = undefined;
|
|
2143
|
+
|
|
2144
|
+
const archivedEstimate = await callJsonTool("archive_record", {
|
|
2145
|
+
recordType: "estimates",
|
|
2146
|
+
id: estimate._id
|
|
2147
|
+
});
|
|
2148
|
+
assert(archivedEstimate.archivedAt, "Hosted archive_record did not archive the smoke Estimate.");
|
|
2149
|
+
createdEstimateId = undefined;
|
|
2150
|
+
|
|
2151
|
+
const archivedBenefits = await callJsonTool("archive_record", {
|
|
2152
|
+
recordType: "benefits",
|
|
2153
|
+
id: benefits._id
|
|
2154
|
+
});
|
|
2155
|
+
assert(archivedBenefits.archivedAt, "Hosted archive_record did not archive the smoke Benefits.");
|
|
2156
|
+
createdBenefitsId = undefined;
|
|
2157
|
+
|
|
2158
|
+
const archivedRequirement = await callJsonTool("archive_record", {
|
|
2159
|
+
recordType: "requirements",
|
|
2160
|
+
id: requirement._id
|
|
2161
|
+
});
|
|
2162
|
+
assert(archivedRequirement.archivedAt, "Hosted archive_record did not archive the smoke requirement.");
|
|
2163
|
+
createdRequirementId = undefined;
|
|
2164
|
+
|
|
2165
|
+
const archivedExpectation = await callJsonTool("archive_record", {
|
|
2166
|
+
recordType: "expectations",
|
|
2167
|
+
id: expectation._id
|
|
2168
|
+
});
|
|
2169
|
+
assert(archivedExpectation.archivedAt, "Hosted archive_record did not archive the smoke expectation.");
|
|
2170
|
+
createdExpectationId = undefined;
|
|
2171
|
+
|
|
2172
|
+
const ticket = await callJsonTool("create_dxcomplete_ticket", {
|
|
2173
|
+
title: `DX Complete hosted MCP smoke ticket ${runId}`,
|
|
2174
|
+
body: "Hosted MCP smoke ticket body."
|
|
2175
|
+
});
|
|
2176
|
+
createdTicketId = ticket._id;
|
|
2177
|
+
assert(ticket.recordType === DXCOMPLETE_TICKET_COLLECTION_NAME, "Hosted ticket had the wrong record type.");
|
|
2178
|
+
assert(!ticket.workspaceId, "Hosted DX Complete Ticket should not be workspace-scoped.");
|
|
2179
|
+
assert(ticket.createdBy === actor.actorId, "Hosted DX Complete Ticket was not attributed to the token actor.");
|
|
2180
|
+
assert(ticket.fields.entries?.[0]?.direction === "submitter_entry", "Hosted ticket first entry had the wrong direction.");
|
|
2181
|
+
|
|
2182
|
+
const listed = await callJsonTool("list_dxcomplete_tickets", { limit: 10 });
|
|
2183
|
+
assert(
|
|
2184
|
+
listed.some((record) => record._id === ticket._id),
|
|
2185
|
+
"Hosted list_dxcomplete_tickets did not return the created ticket."
|
|
2186
|
+
);
|
|
2187
|
+
|
|
2188
|
+
const ticketWithReply = await appendDxcompleteTicketReply(
|
|
2189
|
+
runtime.db,
|
|
2190
|
+
{
|
|
2191
|
+
id: ticket._id,
|
|
2192
|
+
body: "Hosted DX Complete smoke reply.",
|
|
2193
|
+
addressedToActorId: actor.actorId
|
|
2194
|
+
},
|
|
2195
|
+
smokeCleanupActorId
|
|
2196
|
+
);
|
|
2197
|
+
const reply = ticketWithReply.fields.entries.find((entry) => entry.direction === "dxcomplete_reply");
|
|
2198
|
+
assert(reply?.addressedToActorId === actor.actorId, "Hosted DX Complete reply was not addressed to the actor.");
|
|
2199
|
+
|
|
2200
|
+
const unreadReplies = await callJsonTool("list_unread_dxcomplete_ticket_replies", { limit: 10 });
|
|
2201
|
+
const unreadTicket = unreadReplies.find((entry) => entry.ticketId === ticket._id);
|
|
2202
|
+
assert(
|
|
2203
|
+
unreadTicket?.replies.some((item) => item.id === reply.id),
|
|
2204
|
+
"Hosted unread reply list did not include the DX Complete reply."
|
|
2205
|
+
);
|
|
2206
|
+
assert(unreadTicket.unreadReplyCount === 1, "Hosted unread reply list did not include the expected unread reply count.");
|
|
2207
|
+
assert(unreadTicket.newestReplyAt === reply.createdAt, "Hosted unread reply list did not include the newest reply timestamp.");
|
|
2208
|
+
assert(
|
|
2209
|
+
unreadTicket.replies.every((item) => item.body === undefined && item.readAt === undefined),
|
|
2210
|
+
"Hosted unread reply list should not expose full reply bodies or read state."
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
const readTicket = await callJsonTool("read_dxcomplete_ticket", {
|
|
2214
|
+
id: ticket._id
|
|
2215
|
+
});
|
|
2216
|
+
assert(
|
|
2217
|
+
readTicket.fields.entries.some((entry) => entry.id === reply.id && entry.readAt),
|
|
2218
|
+
"Hosted read_dxcomplete_ticket did not set readAt."
|
|
2219
|
+
);
|
|
2220
|
+
|
|
2221
|
+
const unreadAfterRead = await callJsonTool("list_unread_dxcomplete_ticket_replies", { limit: 10 });
|
|
2222
|
+
assert(
|
|
2223
|
+
!unreadAfterRead.some((entry) => entry.ticketId === ticket._id && entry.replies.some((item) => item.id === reply.id)),
|
|
2224
|
+
"Hosted read reply still appeared in unread results."
|
|
2225
|
+
);
|
|
2226
|
+
|
|
2227
|
+
const archived = await callJsonTool("archive_dxcomplete_ticket", { id: ticket._id });
|
|
2228
|
+
assert(archived.archivedAt, "Hosted archive_dxcomplete_ticket did not archive the ticket.");
|
|
2229
|
+
|
|
2230
|
+
console.log(JSON.stringify({
|
|
2231
|
+
ok: true,
|
|
2232
|
+
depth: "deep",
|
|
2233
|
+
areas: selectableSmokeAreas,
|
|
2234
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
2235
|
+
surfaceVersion: status.server.surfaceVersion,
|
|
2236
|
+
surfaceFingerprint: status.server.surfaceFingerprint,
|
|
2237
|
+
toolCount: status.server.toolCount,
|
|
2238
|
+
totalSeconds: elapsedSeconds(smokeStartedAt),
|
|
2239
|
+
mcpUrl
|
|
2240
|
+
}, null, 2));
|
|
2241
|
+
} finally {
|
|
2242
|
+
await archiveCreatedRecord("decisions", createdDecisionId);
|
|
2243
|
+
await archiveCreatedRecord("requirements", createdRequirementId);
|
|
2244
|
+
await archiveCreatedRecord("estimates", createdEstimateId);
|
|
2245
|
+
await archiveCreatedRecord("benefits", createdBenefitsId);
|
|
2246
|
+
await archiveCreatedRecord("changes", createdChangeId);
|
|
2247
|
+
await archiveCreatedRecord("commitments", createdCommitmentId);
|
|
2248
|
+
await archiveCreatedRecord("deferrals", createdDeferralId);
|
|
2249
|
+
await archiveCreatedRecord("expectations", createdExpectationId);
|
|
2250
|
+
await archiveCreatedRecord("statements", createdStatementId);
|
|
2251
|
+
await archiveCreatedTicket(createdTicketId);
|
|
2252
|
+
await transport?.close?.().catch(() => undefined);
|
|
2253
|
+
await closeDxcompleteHttpRuntime().catch(() => undefined);
|
|
2254
|
+
await runtime?.close?.().catch(() => undefined);
|
|
2255
|
+
await closeServer(server);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function parseSmokeSelection(args) {
|
|
2259
|
+
let depth = "light";
|
|
2260
|
+
const areas = new Set();
|
|
2261
|
+
|
|
2262
|
+
for (const arg of args) {
|
|
2263
|
+
if (arg === "--depth=deep" || arg === "--deep" || arg === "deep" || arg === "full") {
|
|
2264
|
+
depth = "deep";
|
|
2265
|
+
continue;
|
|
2266
|
+
}
|
|
2267
|
+
if (arg === "--depth=light" || arg === "--light" || arg === "light") {
|
|
2268
|
+
depth = "light";
|
|
2269
|
+
continue;
|
|
2270
|
+
}
|
|
2271
|
+
if (arg.startsWith("--area=")) {
|
|
2272
|
+
for (const area of arg.slice("--area=".length).split(",")) {
|
|
2273
|
+
areas.add(readSmokeArea(area));
|
|
2274
|
+
}
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
2277
|
+
if (arg.startsWith("--areas=")) {
|
|
2278
|
+
for (const area of arg.slice("--areas=".length).split(",")) {
|
|
2279
|
+
areas.add(readSmokeArea(area));
|
|
2280
|
+
}
|
|
2281
|
+
continue;
|
|
2282
|
+
}
|
|
2283
|
+
if (selectableSmokeAreas.includes(arg)) {
|
|
2284
|
+
areas.add(arg);
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
throw new Error(`Unknown smoke argument: ${arg}`);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
return {
|
|
2291
|
+
runner: "selected",
|
|
2292
|
+
depth,
|
|
2293
|
+
areas: areas.size > 0 ? [...areas] : depth === "deep" ? selectableSmokeAreas : ["surface", "light_write"]
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function readSmokeArea(value) {
|
|
2298
|
+
const area = value.trim();
|
|
2299
|
+
if (area === "light_write") {
|
|
2300
|
+
return area;
|
|
2301
|
+
}
|
|
2302
|
+
if (!selectableSmokeAreas.includes(area)) {
|
|
2303
|
+
throw new Error(`Unknown smoke area: ${value}`);
|
|
2304
|
+
}
|
|
2305
|
+
return area;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
async function runSelectedSmoke(selection) {
|
|
2309
|
+
const timings = [];
|
|
2310
|
+
const cleanupRecords = [];
|
|
2311
|
+
const cleanupTickets = [];
|
|
2312
|
+
let status;
|
|
2313
|
+
let mcpUrl;
|
|
2314
|
+
|
|
2315
|
+
try {
|
|
2316
|
+
const setup = await timedSmokeSection(timings, "setup", setupSelectedHostedSmoke);
|
|
2317
|
+
mcpUrl = setup.mcpUrl;
|
|
2318
|
+
|
|
2319
|
+
if (selection.areas.includes("surface")) {
|
|
2320
|
+
status = await timedSmokeSection(timings, "surface", () => runSurfaceSection({ mcpUrl: setup.mcpUrl, actor: setup.actor }));
|
|
2321
|
+
} else {
|
|
2322
|
+
status = await readSmokeStatus({ mcpUrl: setup.mcpUrl, actor: setup.actor });
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
for (const area of selection.areas) {
|
|
2326
|
+
if (area === "surface") {
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
if (area === "light_write") {
|
|
2330
|
+
await timedSmokeSection(timings, "light_write", () => runLightWriteSection(cleanupRecords));
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
await timedSmokeSection(timings, area, () => runSmokeAreaSection(area, cleanupRecords, cleanupTickets));
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
const totalSeconds = elapsedSeconds(smokeStartedAt);
|
|
2337
|
+
console.log(JSON.stringify({
|
|
2338
|
+
ok: true,
|
|
2339
|
+
depth: selection.depth,
|
|
2340
|
+
areas: selection.areas,
|
|
2341
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
2342
|
+
surfaceVersion: status.server.surfaceVersion,
|
|
2343
|
+
surfaceFingerprint: status.server.surfaceFingerprint,
|
|
2344
|
+
toolCount: status.server.toolCount,
|
|
2345
|
+
totalSeconds,
|
|
2346
|
+
sectionTimings: timings,
|
|
2347
|
+
mcpUrl
|
|
2348
|
+
}, null, 2));
|
|
2349
|
+
} finally {
|
|
2350
|
+
await cleanupSelectedSmokeArtifacts(cleanupRecords, cleanupTickets);
|
|
2351
|
+
await withTimeout(client?.close?.() ?? Promise.resolve(), 2_000, "MCP client close timed out.").catch(() => undefined);
|
|
2352
|
+
await withTimeout(transport?.close?.() ?? Promise.resolve(), 2_000, "MCP transport close timed out.").catch(() => undefined);
|
|
2353
|
+
await closeDxcompleteHttpRuntime().catch(() => undefined);
|
|
2354
|
+
await closeDxcompleteServiceRuntime().catch(() => undefined);
|
|
2355
|
+
await runtime?.close?.().catch(() => undefined);
|
|
2356
|
+
await closeServer(server);
|
|
2357
|
+
await closeServer(serviceServer);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
async function setupSelectedHostedSmoke() {
|
|
2362
|
+
smokeStep("connecting runtime");
|
|
2363
|
+
runtime = await connectRuntime({ envFile: path.join(rootDir, ".env.local") });
|
|
2364
|
+
workspaceConfig = await loadWorkspaceConfig({ cwd: rootDir });
|
|
2365
|
+
|
|
2366
|
+
await archiveStaleSmokeArtifacts(runtime.db, workspaceConfig.workspaceId);
|
|
2367
|
+
|
|
2368
|
+
const actor = createGoogleActorContext({
|
|
2369
|
+
email: `http-smoke-${runId}@example.com`,
|
|
2370
|
+
subject: runId,
|
|
2371
|
+
displayName: "HTTP Smoke Actor"
|
|
2372
|
+
});
|
|
2373
|
+
smokeActor = actor;
|
|
2374
|
+
|
|
2375
|
+
smokeStep("starting central service");
|
|
2376
|
+
serviceServer = createServer((req, res) => {
|
|
2377
|
+
void handleDxcompleteServiceRequest(req, res);
|
|
2378
|
+
});
|
|
2379
|
+
const serviceBaseUrl = await listen(serviceServer);
|
|
2380
|
+
|
|
2381
|
+
const missingServiceCredentialResponse = await fetchWithTimeout(`${serviceBaseUrl}/api/dxcomplete/service/mcp`, {
|
|
2382
|
+
method: "POST",
|
|
2383
|
+
headers: { "content-type": "application/json" },
|
|
2384
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
|
2385
|
+
});
|
|
2386
|
+
assert(missingServiceCredentialResponse.status === 401, "Central service did not reject missing service credentials.");
|
|
2387
|
+
|
|
2388
|
+
const invalidServiceCredentialResponse = await fetchWithTimeout(`${serviceBaseUrl}/api/dxcomplete/service/mcp`, {
|
|
2389
|
+
method: "POST",
|
|
2390
|
+
headers: {
|
|
2391
|
+
"content-type": "application/json",
|
|
2392
|
+
"x-dxc-service-client-id": "invalid",
|
|
2393
|
+
"x-dxc-service-client-secret": "invalid",
|
|
2394
|
+
"x-dxc-workspace-id": workspaceConfig.workspaceId
|
|
2395
|
+
},
|
|
2396
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
|
2397
|
+
});
|
|
2398
|
+
assert(invalidServiceCredentialResponse.status === 401, "Central service did not reject invalid service credentials.");
|
|
2399
|
+
|
|
2400
|
+
const provisionResponse = await fetchWithTimeout(`${serviceBaseUrl}/api/dxcomplete/service/provision`, {
|
|
2401
|
+
method: "POST",
|
|
2402
|
+
headers: {
|
|
2403
|
+
authorization: `Bearer ${process.env.DXC_SERVICE_PROVISIONING_SECRET}`,
|
|
2404
|
+
"content-type": "application/json"
|
|
2405
|
+
},
|
|
2406
|
+
body: JSON.stringify({
|
|
2407
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
2408
|
+
name: workspaceConfig.name,
|
|
2409
|
+
...(workspaceConfig.mode ? { mode: workspaceConfig.mode } : {}),
|
|
2410
|
+
ownerEmail: actor.email,
|
|
2411
|
+
serviceClientName: `DX Complete HTTP smoke ${runId}`
|
|
2412
|
+
})
|
|
2413
|
+
});
|
|
2414
|
+
assert(provisionResponse.status === 201, "Central provisioning endpoint failed.");
|
|
2415
|
+
const provisioned = await provisionResponse.json();
|
|
2416
|
+
assert(provisioned.ownerMembership?.roles?.includes("owner"), "Provisioning did not seed owner membership.");
|
|
2417
|
+
assert(provisioned.serviceClient?.clientId && provisioned.serviceClient?.secret, "Provisioning did not return a service client.");
|
|
2418
|
+
createdServiceClientId = provisioned.serviceClient.clientId;
|
|
2419
|
+
process.env.DXC_SERVICE_URL = serviceBaseUrl;
|
|
2420
|
+
process.env.DXC_SERVICE_CLIENT_ID = provisioned.serviceClient.clientId;
|
|
2421
|
+
process.env.DXC_SERVICE_CLIENT_SECRET = provisioned.serviceClient.secret;
|
|
2422
|
+
|
|
2423
|
+
smokeStep("starting local workspace MCP proxy");
|
|
2424
|
+
server = createServer((req, res) => {
|
|
2425
|
+
void handleDxcompleteWorkspaceHttpRequest(req, res);
|
|
2426
|
+
});
|
|
2427
|
+
const baseUrl = await listen(server);
|
|
2428
|
+
const mcpUrl = `${baseUrl}/api/mcp`;
|
|
2429
|
+
|
|
2430
|
+
smokeStep("checking unauthenticated metadata");
|
|
2431
|
+
const unauthorized = await fetchWithTimeout(mcpUrl, {
|
|
2432
|
+
method: "POST",
|
|
2433
|
+
headers: { "content-type": "application/json" },
|
|
2434
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
|
2435
|
+
});
|
|
2436
|
+
assert(unauthorized.status === 401, "Unauthenticated MCP request did not return 401.");
|
|
2437
|
+
assert(
|
|
2438
|
+
unauthorized.headers.get("www-authenticate")?.includes("/.well-known/oauth-protected-resource/api/mcp"),
|
|
2439
|
+
"Unauthenticated MCP request did not advertise protected-resource metadata."
|
|
2440
|
+
);
|
|
2441
|
+
|
|
2442
|
+
const protectedMetadataResponse = await fetchWithTimeout(`${baseUrl}/.well-known/oauth-protected-resource/api/mcp`);
|
|
2443
|
+
assert(protectedMetadataResponse.ok, "Protected-resource metadata was not reachable.");
|
|
2444
|
+
const protectedMetadata = await protectedMetadataResponse.json();
|
|
2445
|
+
assert(protectedMetadata.resource === mcpUrl, "Protected-resource metadata did not match the exact MCP URL.");
|
|
2446
|
+
|
|
2447
|
+
const authMetadataResponse = await fetchWithTimeout(`${baseUrl}/.well-known/oauth-authorization-server`);
|
|
2448
|
+
assert(authMetadataResponse.ok, "Authorization server metadata was not reachable.");
|
|
2449
|
+
const authMetadata = await authMetadataResponse.json();
|
|
2450
|
+
assert(
|
|
2451
|
+
authMetadata.registration_endpoint === `${baseUrl}/api/dxcomplete/auth/register`,
|
|
2452
|
+
"Authorization server metadata did not expose dynamic client registration."
|
|
2453
|
+
);
|
|
2454
|
+
|
|
2455
|
+
const registrationResponse = await fetchWithTimeout(`${baseUrl}/api/dxcomplete/auth/register`, {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers: { "content-type": "application/json" },
|
|
2458
|
+
body: JSON.stringify({
|
|
2459
|
+
client_name: `DX Complete HTTP smoke ${runId}`,
|
|
2460
|
+
redirect_uris: ["https://client.example/callback"]
|
|
2461
|
+
})
|
|
2462
|
+
});
|
|
2463
|
+
assert(registrationResponse.status === 201, "OAuth dynamic client registration failed.");
|
|
2464
|
+
const registration = await registrationResponse.json();
|
|
2465
|
+
|
|
2466
|
+
const authorizeUrl = new URL(`${baseUrl}/api/dxcomplete/auth/authorize`);
|
|
2467
|
+
authorizeUrl.searchParams.set("client_id", registration.client_id);
|
|
2468
|
+
authorizeUrl.searchParams.set("redirect_uri", "https://client.example/callback");
|
|
2469
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
2470
|
+
authorizeUrl.searchParams.set("code_challenge", "smoke-code-challenge");
|
|
2471
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
2472
|
+
authorizeUrl.searchParams.set("resource", mcpUrl);
|
|
2473
|
+
const authorizeResponse = await fetchWithTimeout(authorizeUrl, { redirect: "manual" });
|
|
2474
|
+
assert(authorizeResponse.status === 302, "OAuth authorize did not redirect to Google.");
|
|
2475
|
+
const googleLocation = new URL(authorizeResponse.headers.get("location"));
|
|
2476
|
+
assert(googleLocation.hostname === "accounts.google.com", "OAuth authorize did not redirect to Google.");
|
|
2477
|
+
assert(
|
|
2478
|
+
googleLocation.searchParams.get("redirect_uri") === `${baseUrl}/api/auth/callback/google`,
|
|
2479
|
+
"OAuth authorize did not use /api/auth/callback/google as the Google callback."
|
|
2480
|
+
);
|
|
2481
|
+
|
|
2482
|
+
smokeStep("issuing smoke actor token");
|
|
2483
|
+
|
|
2484
|
+
const tokenExchangeVerifier = `smoke-token-verifier-${runId}`;
|
|
2485
|
+
const tokenExchangeChallenge = createHash("sha256").update(tokenExchangeVerifier).digest("base64url");
|
|
2486
|
+
const tokenExchangeRequest = await createOAuthAuthorizationRequest(runtime.db, {
|
|
2487
|
+
clientId: registration.client_id,
|
|
2488
|
+
redirectUri: "https://client.example/callback",
|
|
2489
|
+
codeChallenge: tokenExchangeChallenge,
|
|
2490
|
+
codeChallengeMethod: "S256",
|
|
2491
|
+
scope: "mcp:tools",
|
|
2492
|
+
resource: mcpUrl,
|
|
2493
|
+
workspaceId: workspaceConfig.workspaceId
|
|
2494
|
+
});
|
|
2495
|
+
const tokenExchangeCode = await createOAuthAuthorizationCode(runtime.db, tokenExchangeRequest, actor);
|
|
2496
|
+
const tokenExchangeResponse = await fetchWithTimeout(`${baseUrl}/api/dxcomplete/auth/token`, {
|
|
2497
|
+
method: "POST",
|
|
2498
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2499
|
+
body: new URLSearchParams({
|
|
2500
|
+
grant_type: "authorization_code",
|
|
2501
|
+
client_id: registration.client_id,
|
|
2502
|
+
code: tokenExchangeCode,
|
|
2503
|
+
redirect_uri: "https://client.example/callback",
|
|
2504
|
+
code_verifier: tokenExchangeVerifier,
|
|
2505
|
+
resource: mcpUrl
|
|
2506
|
+
})
|
|
2507
|
+
});
|
|
2508
|
+
assert(tokenExchangeResponse.status === 200, "OAuth authorization_code token exchange failed.");
|
|
2509
|
+
const tokenExchangeBody = await tokenExchangeResponse.json();
|
|
2510
|
+
assert(typeof tokenExchangeBody.access_token === "string", "OAuth token response did not include an access token.");
|
|
2511
|
+
await runtime.db.collection("oauth_authorization_requests").deleteOne({ _id: tokenExchangeRequest._id });
|
|
2512
|
+
|
|
2513
|
+
const nonMemberActor = createGoogleActorContext({
|
|
2514
|
+
email: `http-smoke-nonmember-${runId}@example.com`,
|
|
2515
|
+
subject: `${runId}-nonmember`
|
|
2516
|
+
});
|
|
2517
|
+
const nonMemberTokenPair = await issueMcpTokenPair(runtime.db, {
|
|
2518
|
+
clientId: smokeCleanupActorId,
|
|
2519
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
2520
|
+
actor: nonMemberActor,
|
|
2521
|
+
resource: mcpUrl,
|
|
2522
|
+
scope: "mcp:tools"
|
|
2523
|
+
});
|
|
2524
|
+
const forbidden = await fetchWithTimeout(mcpUrl, {
|
|
2525
|
+
method: "POST",
|
|
2526
|
+
headers: {
|
|
2527
|
+
authorization: `Bearer ${nonMemberTokenPair.accessToken}`,
|
|
2528
|
+
"content-type": "application/json"
|
|
2529
|
+
},
|
|
2530
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })
|
|
2531
|
+
});
|
|
2532
|
+
assert(forbidden.status === 403, "Non-member token did not return 403.");
|
|
2533
|
+
|
|
2534
|
+
smokeStep("connecting MCP client");
|
|
2535
|
+
client = new Client({ name: "dxcomplete-smoke-mcp-http", version: "0.1.0" });
|
|
2536
|
+
transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
|
2537
|
+
requestInit: {
|
|
2538
|
+
headers: {
|
|
2539
|
+
authorization: `Bearer ${tokenExchangeBody.access_token}`
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
await withTimeout(client.connect(transport), smokeTimeoutMs, "MCP client connect timed out.");
|
|
2544
|
+
|
|
2545
|
+
return { mcpUrl, actor, serviceBaseUrl };
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
async function runSurfaceSection({ mcpUrl, actor }) {
|
|
2549
|
+
const status = await readSmokeStatus({ mcpUrl, actor });
|
|
2550
|
+
|
|
2551
|
+
const manifestByName = new Map(status.server.toolManifest.map((tool) => [tool.name, tool]));
|
|
2552
|
+
for (const toolName of [
|
|
2553
|
+
"runtime_status",
|
|
2554
|
+
"get_process_guide",
|
|
2555
|
+
"get_doc",
|
|
2556
|
+
"create_statement",
|
|
2557
|
+
"update_statement",
|
|
2558
|
+
"create_expectation",
|
|
2559
|
+
"create_requirement",
|
|
2560
|
+
"create_task",
|
|
2561
|
+
"append_task_entry",
|
|
2562
|
+
"create_environment",
|
|
2563
|
+
"update_environment",
|
|
2564
|
+
"list_environments",
|
|
2565
|
+
"create_component",
|
|
2566
|
+
"update_component",
|
|
2567
|
+
"list_components",
|
|
2568
|
+
"create_estimate",
|
|
2569
|
+
"create_benefits",
|
|
2570
|
+
"create_change",
|
|
2571
|
+
"create_decision",
|
|
2572
|
+
"append_decision_entry",
|
|
2573
|
+
"link_decision_input",
|
|
2574
|
+
"append_journal_note",
|
|
2575
|
+
"read_journal",
|
|
2576
|
+
"get_journal_entry",
|
|
2577
|
+
"append_journal_summary",
|
|
2578
|
+
"create_dxcomplete_ticket",
|
|
2579
|
+
"read_dxcomplete_ticket"
|
|
2580
|
+
]) {
|
|
2581
|
+
assert(status.server.tools.includes(toolName), `Hosted surface is missing ${toolName}.`);
|
|
2582
|
+
}
|
|
2583
|
+
assert(!status.server.tools.includes("get_dxcomplete_ticket"), "Hosted runtime should not expose get_dxcomplete_ticket.");
|
|
2584
|
+
assert(!status.server.tools.includes("mark_dxcomplete_ticket_replies_read"), "Hosted runtime should not expose mark_dxcomplete_ticket_replies_read.");
|
|
2585
|
+
assert(!status.server.tools.includes("update_decision"), "Hosted runtime should not expose update_decision.");
|
|
2586
|
+
assert(!status.server.tools.includes("update_task"), "Hosted runtime should not expose update_task.");
|
|
2587
|
+
for (const removedToolName of [
|
|
2588
|
+
"get_service_charter",
|
|
2589
|
+
"update_service_charter",
|
|
2590
|
+
"create_initiative",
|
|
2591
|
+
"create_business_case",
|
|
2592
|
+
"create_cost_baseline",
|
|
2593
|
+
"create_cost_actual",
|
|
2594
|
+
"create_benefit_measurement",
|
|
2595
|
+
"create_cost_estimate",
|
|
2596
|
+
"create_benefit_estimate"
|
|
2597
|
+
]) {
|
|
2598
|
+
assert(!status.server.tools.includes(removedToolName), `Hosted runtime should not expose removed tool ${removedToolName}.`);
|
|
2599
|
+
}
|
|
2600
|
+
assert(manifestByName.get("get_doc")?.inputFields.join(",") === "page,term", "Hosted get_doc should expose page and optional term inputs.");
|
|
2601
|
+
for (const toolName of ["get_doc", "create_statement", "update_statement", "create_expectation", "create_task", "append_task_entry", "create_environment", "update_environment", "list_environments", "create_component", "update_component", "list_components", "create_estimate", "create_benefits", "create_change", "create_decision", "append_decision_entry", "link_decision_input", "append_journal_note", "read_journal", "get_journal_entry", "append_journal_summary"]) {
|
|
2602
|
+
assert(!manifestByName.get(toolName)?.inputFields.includes("workspaceId"), `${toolName} should not expose workspaceId on the hosted surface.`);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
return status;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
async function readSmokeStatus({ mcpUrl, actor }) {
|
|
2609
|
+
const status = await callJsonTool("runtime_status", {});
|
|
2610
|
+
assert(status.ok === true, "runtime_status did not return ok: true.");
|
|
2611
|
+
assert(status.actor?.provider === "google", "Hosted runtime did not expose Google actor context.");
|
|
2612
|
+
assert(status.actor?.email === actor.email, "Hosted runtime actor email did not match token actor.");
|
|
2613
|
+
assert(status.workspace?.workspaceId === workspaceConfig.workspaceId, "Hosted runtime did not expose repo workspace config.");
|
|
2614
|
+
assert(status.server?.toolCount === expectedHostedToolCount, `Expected ${expectedHostedToolCount} hosted tools.`);
|
|
2615
|
+
assert(status.server?.surfaceVersion === "dxc-mcp-surface", "Hosted runtime did not expose the expected MCP surface version.");
|
|
2616
|
+
assert(status.server?.workspaceCompatibility === 1, "Hosted runtime did not expose the expected workspace compatibility version.");
|
|
2617
|
+
assert(typeof status.server?.packageVersion === "string", "Hosted runtime did not expose a package version.");
|
|
2618
|
+
assert(typeof status.server?.surfaceFingerprint === "string", "Hosted runtime did not expose a surface fingerprint.");
|
|
2619
|
+
assert(status.server?.http?.canonicalMcpUrl === mcpUrl, "Hosted runtime did not report the canonical MCP URL.");
|
|
2620
|
+
return status;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
async function runSmokeAreaSection(area, cleanupRecords, cleanupTickets) {
|
|
2624
|
+
switch (area) {
|
|
2625
|
+
case "docs":
|
|
2626
|
+
await runDocsSection();
|
|
2627
|
+
return;
|
|
2628
|
+
case "records":
|
|
2629
|
+
await runRecordsSection(cleanupRecords);
|
|
2630
|
+
return;
|
|
2631
|
+
case "weigh":
|
|
2632
|
+
await runWeighSection(cleanupRecords);
|
|
2633
|
+
return;
|
|
2634
|
+
case "change":
|
|
2635
|
+
await runChangeSection(cleanupRecords);
|
|
2636
|
+
return;
|
|
2637
|
+
case "tickets":
|
|
2638
|
+
await runTicketsSection(cleanupTickets);
|
|
2639
|
+
return;
|
|
2640
|
+
case "journal":
|
|
2641
|
+
await runJournalSection(cleanupRecords);
|
|
2642
|
+
return;
|
|
2643
|
+
default:
|
|
2644
|
+
throw new Error(`Unsupported smoke area: ${area}`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
async function runDocsSection() {
|
|
2649
|
+
const status = await callJsonTool("runtime_status", {});
|
|
2650
|
+
const processGuide = await callJsonTool("get_process_guide", {});
|
|
2651
|
+
assert(processGuide.surfaceVersion === status.server.surfaceVersion, "Process guide surface version did not match runtime_status.");
|
|
2652
|
+
assert(processGuide.surfaceFingerprint === status.server.surfaceFingerprint, "Process guide fingerprint did not match runtime_status.");
|
|
2653
|
+
assert(processGuide.currentFlow?.join(",") === "Statement,Expectation,Requirement,Commitment", "Process guide returned an unexpected current flow.");
|
|
2654
|
+
assert(
|
|
2655
|
+
processGuide.phaseGuidance?.some((entry) => entry.includes("non-terminal") && entry.includes("re-enterable")),
|
|
2656
|
+
"Process guide should describe phases as non-terminal and re-enterable."
|
|
2657
|
+
);
|
|
2658
|
+
assert(
|
|
2659
|
+
processGuide.crossCuttingRecords?.some(
|
|
2660
|
+
(entry) =>
|
|
2661
|
+
entry.record === "Task" &&
|
|
2662
|
+
entry.use.includes("Statement, Expectation, Requirement, Commitment")
|
|
2663
|
+
),
|
|
2664
|
+
"Process guide should describe Task as cross-cutting without naming a separate sequence concept."
|
|
2665
|
+
);
|
|
2666
|
+
assert(
|
|
2667
|
+
processGuide.recordRoutingGuidance?.principle?.includes("dedicated record first") &&
|
|
2668
|
+
processGuide.recordRoutingGuidance?.sharpTest?.includes("Will anything reference or depend on this"),
|
|
2669
|
+
"Process guide should include compact record-routing guidance."
|
|
2670
|
+
);
|
|
2671
|
+
assert(processGuide.phases.map((phase) => phase.id).join(",") === "orient,elicit,weigh,build,go_live,operate,measure", "Process guide returned an unexpected phase order.");
|
|
2672
|
+
assert(processGuide.runtimeRecordTypes?.includes("statements"), "Process guide should include statements.");
|
|
2673
|
+
assert(processGuide.runtimeRecordTypes?.includes("journal_entries"), "Process guide should include journal_entries.");
|
|
2674
|
+
assert(processGuide.runtimeRecordTypes?.includes("environments"), "Process guide should include environments.");
|
|
2675
|
+
assert(processGuide.runtimeRecordTypes?.includes("components"), "Process guide should include components.");
|
|
2676
|
+
assert(processGuide.runtimeRecordTypes?.includes("benefits"), "Process guide should include benefits.");
|
|
2677
|
+
assert(!processGuide.runtimeRecordTypes?.includes("initiatives"), "Process guide should not include initiatives.");
|
|
2678
|
+
for (const removedRuntimeRecordType of ["service_charters", "cost_baselines", "cost_actuals", "benefit_measurements"]) {
|
|
2679
|
+
assert(!processGuide.runtimeRecordTypes?.includes(removedRuntimeRecordType), `Process guide should not include ${removedRuntimeRecordType}.`);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const rolesDoc = await callJsonTool("get_doc", { page: "roles" });
|
|
2683
|
+
assert(rolesDoc.surfaceFingerprint === status.server.surfaceFingerprint, "Roles doc fingerprint did not match runtime_status.");
|
|
2684
|
+
assert(rolesDoc.roles?.map((role) => role.name).join(",") === "Owner,Engineer,Tester,Operator,Support Agent,End User", "Roles doc did not return the locked six-role model.");
|
|
2685
|
+
|
|
2686
|
+
const recordsDoc = await callJsonTool("get_doc", { page: "records" });
|
|
2687
|
+
const recordNames = recordsDoc.groups?.flatMap((group) => group.records.map((record) => record.name)) ?? [];
|
|
2688
|
+
assert(
|
|
2689
|
+
recordNames.includes("Statement") &&
|
|
2690
|
+
recordNames.includes("Journal") &&
|
|
2691
|
+
recordNames.includes("Environment") &&
|
|
2692
|
+
recordNames.includes("Component") &&
|
|
2693
|
+
recordNames.includes("Estimate") &&
|
|
2694
|
+
recordNames.includes("Benefits"),
|
|
2695
|
+
"Records doc should include Statement, Journal, Environment, Component, Estimate, and Benefits."
|
|
2696
|
+
);
|
|
2697
|
+
assert(
|
|
2698
|
+
recordsDoc.routingGuidance?.sharpTest?.includes("Will anything reference or depend on this") &&
|
|
2699
|
+
recordsDoc.routingGuidance?.axes?.some((axis) => axis.name === "Attachment") &&
|
|
2700
|
+
recordsDoc.routingGuidance?.promotion?.includes("promoted") &&
|
|
2701
|
+
recordsDoc.routingGuidance?.futureContainers?.some((entry) => entry.name === "Operational Registry"),
|
|
2702
|
+
"Records doc should include full record-routing guidance."
|
|
2703
|
+
);
|
|
2704
|
+
for (const removedRecordName of ["Service Charter", "Cost Baseline", "Actual Cost", "Benefit Measurement", "Initiative", "Business Case", "Cost Estimate", "Benefit Estimate"]) {
|
|
2705
|
+
assert(!recordNames.includes(removedRecordName), `Records doc should not include removed record ${removedRecordName}.`);
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const statementDoc = await callJsonTool("get_doc", { page: "glossary", term: "statement" });
|
|
2709
|
+
assert(statementDoc.term?.definition.includes("traceable root"), "Statement glossary term should describe traceability.");
|
|
2710
|
+
const benefitsDoc = await callJsonTool("get_doc", { page: "glossary", term: "benefits" });
|
|
2711
|
+
assert(benefitsDoc.term?.definition.includes("quantified or qualitative"), "Benefits glossary term should mention quantified or qualitative items.");
|
|
2712
|
+
const journalDoc = await callJsonTool("get_doc", { page: "glossary", term: "journal" });
|
|
2713
|
+
assert(journalDoc.term?.definition.includes("shared workspace"), "Journal glossary term should describe shared workspace context.");
|
|
2714
|
+
const readableIdDoc = await callJsonTool("get_doc", { page: "glossary", term: "readable id" });
|
|
2715
|
+
assert(readableIdDoc.term?.definition.includes("REQ-0001"), "Readable ID glossary term should mention the human-facing reference format.");
|
|
2716
|
+
const registryDoc = await callJsonTool("get_doc", { page: "glossary", term: "operational registry" });
|
|
2717
|
+
assert(
|
|
2718
|
+
registryDoc.term?.definition.includes("Environments and Components") &&
|
|
2719
|
+
registryDoc.term?.definition.includes("not monitoring"),
|
|
2720
|
+
"Operational Registry glossary term should describe inventory-only boundaries."
|
|
2721
|
+
);
|
|
2722
|
+
const componentDoc = await callJsonTool("get_doc", { page: "glossary", term: "component" });
|
|
2723
|
+
assert(componentDoc.term?.definition.includes("one Environment"), "Component glossary term should describe one-environment scope.");
|
|
2724
|
+
const secretPointerDoc = await callJsonTool("get_doc", { page: "glossary", term: "secret pointer" });
|
|
2725
|
+
assert(secretPointerDoc.term?.definition.includes("should not contain the secret value"), "Secret Pointer glossary term should warn against secret values.");
|
|
2726
|
+
await expectToolError("get_doc", { page: "glossary", term: "voice" }, "Glossary term not found");
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
async function runLightWriteSection(cleanupRecords) {
|
|
2730
|
+
const expectation = await callJsonTool("create_expectation", {
|
|
2731
|
+
title: `DX Complete hosted MCP smoke light expectation ${runId}`,
|
|
2732
|
+
statement: "The light smoke run can create and archive one workspace-scoped record.",
|
|
2733
|
+
successRecognition: "The record can be created, read, and archived through the hosted MCP endpoint."
|
|
2734
|
+
});
|
|
2735
|
+
cleanupRecords.push(["expectations", expectation._id]);
|
|
2736
|
+
assert(/^EXP-\d{4,}$/.test(expectation.readableId ?? ""), "Light smoke expectation did not receive a readable ID.");
|
|
2737
|
+
const fetched = await callJsonTool("get_record", { recordType: "expectations", id: expectation._id });
|
|
2738
|
+
assert(fetched._id === expectation._id, "Light smoke did not read the created expectation.");
|
|
2739
|
+
const fetchedByReadableId = await callJsonTool("get_record", { recordType: "expectations", id: expectation.readableId });
|
|
2740
|
+
assert(fetchedByReadableId._id === expectation._id, "Light smoke did not read the created expectation by readable ID.");
|
|
2741
|
+
const archived = await callJsonTool("archive_record", { recordType: "expectations", id: expectation.readableId });
|
|
2742
|
+
assert(archived.archivedAt, "Light smoke did not archive the created expectation.");
|
|
2743
|
+
assert(archived.readableId === expectation.readableId, "Light smoke archive did not preserve readable ID.");
|
|
2744
|
+
cleanupRecords.splice(cleanupRecords.findIndex((entry) => entry[0] === "expectations" && entry[1] === expectation._id), 1);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
async function createExpectationRequirementFixture(cleanupRecords) {
|
|
2748
|
+
const statement = await callJsonTool("create_statement", {
|
|
2749
|
+
title: `DX Complete hosted MCP smoke statement ${runId}`,
|
|
2750
|
+
statement: "The selected smoke run can preserve a source statement.",
|
|
2751
|
+
source: "hosted smoke"
|
|
2752
|
+
});
|
|
2753
|
+
cleanupRecords.push(["statements", statement._id]);
|
|
2754
|
+
assert(/^STM-\d{4,}$/.test(statement.readableId ?? ""), "Statement did not receive a readable ID.");
|
|
2755
|
+
const expectation = await callJsonTool("create_expectation", {
|
|
2756
|
+
title: `DX Complete hosted MCP smoke expectation ${runId}`,
|
|
2757
|
+
statementId: statement._id,
|
|
2758
|
+
statement: "The selected smoke run can track an expectation.",
|
|
2759
|
+
successRecognition: "The expectation can be linked and cleaned up."
|
|
2760
|
+
});
|
|
2761
|
+
cleanupRecords.push(["expectations", expectation._id]);
|
|
2762
|
+
assert(/^EXP-\d{4,}$/.test(expectation.readableId ?? ""), "Expectation did not receive a readable ID.");
|
|
2763
|
+
const requirement = await callJsonTool("create_requirement", {
|
|
2764
|
+
title: `DX Complete hosted MCP smoke requirement ${runId}`,
|
|
2765
|
+
expectationId: expectation._id,
|
|
2766
|
+
statement: "The selected smoke run can track a requirement.",
|
|
2767
|
+
acceptanceCriteria: ["The requirement links to its expectation."]
|
|
2768
|
+
});
|
|
2769
|
+
cleanupRecords.push(["requirements", requirement._id]);
|
|
2770
|
+
assert(/^REQ-\d{4,}$/.test(requirement.readableId ?? ""), "Requirement did not receive a readable ID.");
|
|
2771
|
+
return { statement, expectation, requirement };
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
async function runRecordsSection(cleanupRecords) {
|
|
2775
|
+
const { statement, expectation, requirement } = await createExpectationRequirementFixture(cleanupRecords);
|
|
2776
|
+
const statementLinks = await callJsonTool("list_linked_records", {
|
|
2777
|
+
recordType: "expectations",
|
|
2778
|
+
id: expectation._id,
|
|
2779
|
+
relationship: "derives_from"
|
|
2780
|
+
});
|
|
2781
|
+
assert(statementLinks.outbound.some((entry) => entry.record._id === statement._id), "Records smoke did not return derives_from Statement link.");
|
|
2782
|
+
const updatedExpectation = await callJsonTool("update_expectation", {
|
|
2783
|
+
id: expectation._id,
|
|
2784
|
+
approvalState: "not_approved",
|
|
2785
|
+
revisionNote: "Selected records smoke revision."
|
|
2786
|
+
});
|
|
2787
|
+
assert(updatedExpectation.fields.versionHistory?.length === 1, "Records smoke did not append expectation version history.");
|
|
2788
|
+
const requirementWithReviewNote = await callJsonTool("append_review_note", {
|
|
2789
|
+
recordType: "requirements",
|
|
2790
|
+
id: requirement._id,
|
|
2791
|
+
body: "Selected records smoke review note.",
|
|
2792
|
+
important: true
|
|
2793
|
+
});
|
|
2794
|
+
assert(requirementWithReviewNote.fields.reviewNotes?.length === 1, "Records smoke did not append review note.");
|
|
2795
|
+
const linked = await callJsonTool("list_linked_records", {
|
|
2796
|
+
recordType: "requirements",
|
|
2797
|
+
id: requirement.readableId,
|
|
2798
|
+
relationship: "satisfies"
|
|
2799
|
+
});
|
|
2800
|
+
assert(linked.outbound.some((entry) => entry.record._id === expectation._id), "Records smoke did not return satisfies link.");
|
|
2801
|
+
const task = await callJsonTool("create_task", {
|
|
2802
|
+
title: `DX Complete hosted MCP smoke task ${runId}`,
|
|
2803
|
+
description: "Exercise the ledger-style Task shape.",
|
|
2804
|
+
requirementId: requirement._id,
|
|
2805
|
+
assignee: "Engineer",
|
|
2806
|
+
assignor: "Owner",
|
|
2807
|
+
initialStatus: "open"
|
|
2808
|
+
});
|
|
2809
|
+
cleanupRecords.push(["tasks", task._id]);
|
|
2810
|
+
assert(task.fields.currentStatus?.status === "open", "Records smoke task did not derive initial current status.");
|
|
2811
|
+
assert(task.fields.entries?.length === 1, "Records smoke task did not seed an initial status entry.");
|
|
2812
|
+
await expectToolError("create_record", {
|
|
2813
|
+
recordType: "tasks",
|
|
2814
|
+
title: `DX Complete hosted MCP direct task entries ${runId}`,
|
|
2815
|
+
fields: { entries: [] }
|
|
2816
|
+
}, "append_task_entry");
|
|
2817
|
+
const activeTask = await callJsonTool("append_task_entry", {
|
|
2818
|
+
taskId: task._id,
|
|
2819
|
+
entryType: "status_change",
|
|
2820
|
+
body: "Work started.",
|
|
2821
|
+
status: "in_progress"
|
|
2822
|
+
});
|
|
2823
|
+
assert(
|
|
2824
|
+
activeTask.fields.currentStatus?.status === "in_progress" &&
|
|
2825
|
+
activeTask.fields.entries?.length === 2,
|
|
2826
|
+
"Records smoke append_task_entry did not derive current status from the latest status_change entry."
|
|
2827
|
+
);
|
|
2828
|
+
const commentedTask = await callJsonTool("append_task_entry", {
|
|
2829
|
+
taskId: task._id,
|
|
2830
|
+
entryType: "comment",
|
|
2831
|
+
body: "Progress comment."
|
|
2832
|
+
});
|
|
2833
|
+
assert(
|
|
2834
|
+
commentedTask.fields.currentStatus?.status === "in_progress" &&
|
|
2835
|
+
commentedTask.fields.entries?.length === 3,
|
|
2836
|
+
"Records smoke task comment should not change current status."
|
|
2837
|
+
);
|
|
2838
|
+
await expectToolError("append_task_entry", {
|
|
2839
|
+
taskId: task._id,
|
|
2840
|
+
entryType: "comment",
|
|
2841
|
+
body: "Invalid task comment.",
|
|
2842
|
+
status: "done"
|
|
2843
|
+
}, "status is only valid");
|
|
2844
|
+
await expectToolError("update_record", {
|
|
2845
|
+
recordType: "tasks",
|
|
2846
|
+
id: task._id,
|
|
2847
|
+
fields: { currentStatus: { status: "done" } }
|
|
2848
|
+
}, "append_task_entry");
|
|
2849
|
+
await expectToolError("update_record", {
|
|
2850
|
+
recordType: "tasks",
|
|
2851
|
+
id: task._id,
|
|
2852
|
+
fields: { status: "done" }
|
|
2853
|
+
}, "append_task_entry");
|
|
2854
|
+
await expectToolError("update_record", {
|
|
2855
|
+
recordType: "tasks",
|
|
2856
|
+
id: task._id,
|
|
2857
|
+
unsetFields: ["currentStatus"]
|
|
2858
|
+
}, "append_task_entry");
|
|
2859
|
+
const listedTasks = await callJsonTool("list_records", { recordType: "tasks", includeArchived: true, limit: 100 });
|
|
2860
|
+
assert(
|
|
2861
|
+
listedTasks.every(
|
|
2862
|
+
(record) =>
|
|
2863
|
+
Array.isArray(record.fields.entries) &&
|
|
2864
|
+
record.fields.entries.length > 0 &&
|
|
2865
|
+
record.fields.description &&
|
|
2866
|
+
record.fields.currentStatus &&
|
|
2867
|
+
!Object.hasOwn(record.fields, "status") &&
|
|
2868
|
+
!Object.hasOwn(record.fields, "details")
|
|
2869
|
+
),
|
|
2870
|
+
"Records smoke found a Task without the ledger shape."
|
|
2871
|
+
);
|
|
2872
|
+
await expectToolError("create_requirement", {
|
|
2873
|
+
title: `DX Complete hosted MCP invalid readable sequence requirement ${runId}`,
|
|
2874
|
+
statement: "This create should fail before allocation.",
|
|
2875
|
+
fields: { expectationId: expectation._id }
|
|
2876
|
+
}, "expectationId");
|
|
2877
|
+
const afterFailedCreate = await callJsonTool("create_requirement", {
|
|
2878
|
+
title: `DX Complete hosted MCP sequence requirement ${runId}`,
|
|
2879
|
+
expectationId: expectation._id,
|
|
2880
|
+
statement: "The next valid create should not skip a readable ID after a failed create.",
|
|
2881
|
+
acceptanceCriteria: ["The readable ID increments by exactly one from the prior requirement."]
|
|
2882
|
+
});
|
|
2883
|
+
cleanupRecords.push(["requirements", afterFailedCreate._id]);
|
|
2884
|
+
assert(
|
|
2885
|
+
readableIdNumber(afterFailedCreate.readableId) === readableIdNumber(requirement.readableId) + 1,
|
|
2886
|
+
"Failed create burned a readable ID sequence number."
|
|
2887
|
+
);
|
|
2888
|
+
const environment = await callJsonTool("create_environment", {
|
|
2889
|
+
name: `DX Complete hosted MCP smoke environment ${runId}`,
|
|
2890
|
+
summary: "Records smoke environment.",
|
|
2891
|
+
description: "A temporary operating context for registry smoke coverage."
|
|
2892
|
+
});
|
|
2893
|
+
cleanupRecords.push(["environments", environment._id]);
|
|
2894
|
+
assert(environment.recordType === "environments", "Records smoke Environment had the wrong record type.");
|
|
2895
|
+
assert(/^ENV-\d{4,}$/.test(environment.readableId ?? ""), "Environment did not receive an ENV readable ID.");
|
|
2896
|
+
assert(environment.fields.name.includes(runId), "Environment name was not stored.");
|
|
2897
|
+
const updatedEnvironment = await callJsonTool("update_environment", {
|
|
2898
|
+
id: environment._id,
|
|
2899
|
+
description: "Updated temporary operating context for registry smoke coverage.",
|
|
2900
|
+
revisionNote: "Records smoke environment revision."
|
|
2901
|
+
});
|
|
2902
|
+
assert(
|
|
2903
|
+
updatedEnvironment.fields.description?.includes("Updated temporary") &&
|
|
2904
|
+
updatedEnvironment.fields.versionHistory?.length === 1,
|
|
2905
|
+
"Records smoke update_environment did not update versioned state."
|
|
2906
|
+
);
|
|
2907
|
+
const component = await callJsonTool("create_component", {
|
|
2908
|
+
name: `DX Complete hosted MCP smoke component ${runId}`,
|
|
2909
|
+
environmentId: environment._id,
|
|
2910
|
+
kind: "temporary smoke service",
|
|
2911
|
+
summary: "Records smoke component.",
|
|
2912
|
+
locator: {
|
|
2913
|
+
url: "https://example.com/smoke",
|
|
2914
|
+
route: "/smoke",
|
|
2915
|
+
region: "local"
|
|
2916
|
+
},
|
|
2917
|
+
identifiers: {
|
|
2918
|
+
project: "dxcomplete-smoke",
|
|
2919
|
+
databaseName: "dxcomplete"
|
|
2920
|
+
},
|
|
2921
|
+
secretPointers: [
|
|
2922
|
+
{
|
|
2923
|
+
store: "Vercel",
|
|
2924
|
+
key: "dxc-smoke-secret",
|
|
2925
|
+
location: "Production",
|
|
2926
|
+
note: "Pointer only; no secret value."
|
|
2927
|
+
}
|
|
2928
|
+
],
|
|
2929
|
+
notes: "Temporary Component for registry smoke coverage."
|
|
2930
|
+
});
|
|
2931
|
+
cleanupRecords.push(["components", component._id]);
|
|
2932
|
+
assert(component.recordType === "components", "Records smoke Component had the wrong record type.");
|
|
2933
|
+
assert(/^CMP-\d{4,}$/.test(component.readableId ?? ""), "Component did not receive a CMP readable ID.");
|
|
2934
|
+
assert(component.fields.environmentId === environment._id, "Component did not store its Environment reference.");
|
|
2935
|
+
assert(component.fields.kind === "temporary smoke service", "Component kind was not stored as free text.");
|
|
2936
|
+
assert(component.fields.locator?.url === "https://example.com/smoke", "Component structured locator was not stored.");
|
|
2937
|
+
assert(component.fields.secretPointers?.[0]?.key === "dxc-smoke-secret", "Component secret pointer was not stored.");
|
|
2938
|
+
assert(
|
|
2939
|
+
component.links?.some(
|
|
2940
|
+
(link) =>
|
|
2941
|
+
link.relationship === "belongs_to_environment" &&
|
|
2942
|
+
link.toType === "environments" &&
|
|
2943
|
+
link.toId === environment._id
|
|
2944
|
+
),
|
|
2945
|
+
"Component did not link to Environment with belongs_to_environment."
|
|
2946
|
+
);
|
|
2947
|
+
const componentsForEnvironment = await callJsonTool("list_components", {
|
|
2948
|
+
environmentId: environment._id,
|
|
2949
|
+
includeArchived: true,
|
|
2950
|
+
limit: 100
|
|
2951
|
+
});
|
|
2952
|
+
assert(
|
|
2953
|
+
componentsForEnvironment.some((record) => record._id === component._id),
|
|
2954
|
+
"list_components did not return the Component for its Environment."
|
|
2955
|
+
);
|
|
2956
|
+
const otherEnvironment = await callJsonTool("create_environment", {
|
|
2957
|
+
name: `DX Complete hosted MCP smoke other environment ${runId}`
|
|
2958
|
+
});
|
|
2959
|
+
cleanupRecords.push(["environments", otherEnvironment._id]);
|
|
2960
|
+
const componentsForOtherEnvironment = await callJsonTool("list_components", {
|
|
2961
|
+
environmentId: otherEnvironment._id,
|
|
2962
|
+
includeArchived: true,
|
|
2963
|
+
limit: 100
|
|
2964
|
+
});
|
|
2965
|
+
assert(
|
|
2966
|
+
!componentsForOtherEnvironment.some((record) => record._id === component._id),
|
|
2967
|
+
"list_components returned a Component from another Environment."
|
|
2968
|
+
);
|
|
2969
|
+
const linkedEnvironment = await callJsonTool("list_linked_records", {
|
|
2970
|
+
recordType: "components",
|
|
2971
|
+
id: component._id,
|
|
2972
|
+
relationship: "belongs_to_environment"
|
|
2973
|
+
});
|
|
2974
|
+
assert(
|
|
2975
|
+
linkedEnvironment.outbound.some((entry) => entry.record._id === environment._id),
|
|
2976
|
+
"Generic link traversal did not return the Component's Environment."
|
|
2977
|
+
);
|
|
2978
|
+
const updatedComponent = await callJsonTool("update_component", {
|
|
2979
|
+
id: component._id,
|
|
2980
|
+
locator: {
|
|
2981
|
+
url: "https://example.com/smoke-updated",
|
|
2982
|
+
route: "/smoke-updated",
|
|
2983
|
+
region: "local"
|
|
2984
|
+
},
|
|
2985
|
+
notes: "Updated temporary Component for registry smoke coverage.",
|
|
2986
|
+
revisionNote: "Records smoke component revision."
|
|
2987
|
+
});
|
|
2988
|
+
assert(
|
|
2989
|
+
updatedComponent.fields.locator?.url === "https://example.com/smoke-updated" &&
|
|
2990
|
+
updatedComponent.fields.versionHistory?.length === 1,
|
|
2991
|
+
"Records smoke update_component did not update versioned state."
|
|
2992
|
+
);
|
|
2993
|
+
await expectToolError("create_component", {
|
|
2994
|
+
name: `DX Complete hosted MCP missing environment component ${runId}`,
|
|
2995
|
+
environmentId: `missing-${runId}`,
|
|
2996
|
+
kind: "temporary smoke service",
|
|
2997
|
+
locator: { url: "https://example.com/missing" }
|
|
2998
|
+
}, "Environment not found");
|
|
2999
|
+
await expectToolError("create_record", {
|
|
3000
|
+
recordType: "components",
|
|
3001
|
+
title: `DX Complete hosted MCP direct component env ${runId}`,
|
|
3002
|
+
fields: { environmentId: environment._id }
|
|
3003
|
+
}, "environmentId");
|
|
3004
|
+
await expectToolError("update_record", {
|
|
3005
|
+
recordType: "components",
|
|
3006
|
+
id: component._id,
|
|
3007
|
+
fields: { locator: { url: "https://example.com/direct" } }
|
|
3008
|
+
}, "update_component");
|
|
3009
|
+
await expectToolError("update_record", {
|
|
3010
|
+
recordType: "environments",
|
|
3011
|
+
id: environment._id,
|
|
3012
|
+
fields: { description: "Direct environment mutation should fail." }
|
|
3013
|
+
}, "update_environment");
|
|
3014
|
+
await expectToolError("update_record", {
|
|
3015
|
+
recordType: "components",
|
|
3016
|
+
id: component._id,
|
|
3017
|
+
unsetFields: ["versionHistory"]
|
|
3018
|
+
}, "versionHistory");
|
|
3019
|
+
await assertReadableIdsContiguous();
|
|
3020
|
+
await expectToolError("update_record", {
|
|
3021
|
+
recordType: "requirements",
|
|
3022
|
+
id: requirement._id,
|
|
3023
|
+
fields: { statement: "Direct mutation should fail." }
|
|
3024
|
+
}, "update_requirement");
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
async function createWeighFixture(cleanupRecords) {
|
|
3028
|
+
const { expectation, requirement } = await createExpectationRequirementFixture(cleanupRecords);
|
|
3029
|
+
return { expectation, requirement };
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
async function runWeighSection(cleanupRecords) {
|
|
3033
|
+
const { expectation, requirement } = await createWeighFixture(cleanupRecords);
|
|
3034
|
+
await expectToolError("create_estimate", {
|
|
3035
|
+
title: `DX Complete hosted MCP bad estimate ${runId}`,
|
|
3036
|
+
requirementIds: [requirement._id],
|
|
3037
|
+
lineItems: [{
|
|
3038
|
+
label: "Direction should fail",
|
|
3039
|
+
direction: "cost",
|
|
3040
|
+
timing: "one_time",
|
|
3041
|
+
currency: "USD",
|
|
3042
|
+
amount: { kind: "single", value: 1 }
|
|
3043
|
+
}]
|
|
3044
|
+
}, "direction");
|
|
3045
|
+
const estimate = await callJsonTool("create_estimate", {
|
|
3046
|
+
title: `DX Complete hosted MCP smoke estimate ${runId}`,
|
|
3047
|
+
requirementIds: [requirement._id],
|
|
3048
|
+
lineItems: [{
|
|
3049
|
+
label: "Build work",
|
|
3050
|
+
timing: "one_time",
|
|
3051
|
+
currency: "USD",
|
|
3052
|
+
amount: { kind: "range", min: 100, expected: 150, max: 200 }
|
|
3053
|
+
}]
|
|
3054
|
+
});
|
|
3055
|
+
cleanupRecords.push(["estimates", estimate._id]);
|
|
3056
|
+
assert(estimate.fields.rollup?.currencies?.USD?.one_time?.expected === 150, "Weigh smoke did not compute estimate roll-up.");
|
|
3057
|
+
|
|
3058
|
+
const benefits = await callJsonTool("create_benefits", {
|
|
3059
|
+
title: `DX Complete hosted MCP smoke benefits ${runId}`,
|
|
3060
|
+
expectationIds: [expectation._id],
|
|
3061
|
+
benefitItems: [{ label: "Qualitative value" }]
|
|
3062
|
+
});
|
|
3063
|
+
cleanupRecords.push(["benefits", benefits._id]);
|
|
3064
|
+
assert(benefits.fields.benefitItems?.[0]?.amount === undefined, "Weigh smoke did not preserve qualitative Benefits item.");
|
|
3065
|
+
|
|
3066
|
+
const deferral = await callJsonTool("create_deferral", {
|
|
3067
|
+
title: `DX Complete hosted MCP smoke deferral ${runId}`,
|
|
3068
|
+
reason: "Selected smoke deferral.",
|
|
3069
|
+
conditions: ["Selected smoke condition."],
|
|
3070
|
+
requirementIds: [requirement._id]
|
|
3071
|
+
});
|
|
3072
|
+
cleanupRecords.push(["deferrals", deferral._id]);
|
|
3073
|
+
const commitment = await callJsonTool("create_commitment", {
|
|
3074
|
+
title: `DX Complete hosted MCP smoke commitment ${runId}`,
|
|
3075
|
+
commitmentStatement: "Selected smoke commitment.",
|
|
3076
|
+
requirementIds: [requirement._id],
|
|
3077
|
+
deferralId: deferral._id
|
|
3078
|
+
});
|
|
3079
|
+
cleanupRecords.push(["commitments", commitment._id]);
|
|
3080
|
+
const commitmentWithBenefits = await callJsonTool("link_records", {
|
|
3081
|
+
fromType: "commitments",
|
|
3082
|
+
fromId: commitment._id,
|
|
3083
|
+
toType: "benefits",
|
|
3084
|
+
toId: benefits._id,
|
|
3085
|
+
relationship: "informed_by"
|
|
3086
|
+
});
|
|
3087
|
+
assert(commitmentWithBenefits.links.some((link) => link.toType === "benefits" && link.relationship === "informed_by"), "Weigh smoke did not link Commitment to Benefits.");
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
async function runChangeSection(cleanupRecords) {
|
|
3091
|
+
const { expectation, requirement } = await createWeighFixture(cleanupRecords);
|
|
3092
|
+
const change = await callJsonTool("create_change", {
|
|
3093
|
+
title: `DX Complete hosted MCP smoke change ${runId}`,
|
|
3094
|
+
changePlan: "Selected smoke change plan.",
|
|
3095
|
+
executionSteps: ["Execute selected smoke change."],
|
|
3096
|
+
rollbackPlan: "Rollback selected smoke change.",
|
|
3097
|
+
riskImpact: "Low selected smoke risk.",
|
|
3098
|
+
requirementId: requirement._id
|
|
3099
|
+
});
|
|
3100
|
+
cleanupRecords.push(["changes", change._id]);
|
|
3101
|
+
const changeWithNotice = await callJsonTool("append_change_event", {
|
|
3102
|
+
changeId: change._id,
|
|
3103
|
+
eventType: "notice_given",
|
|
3104
|
+
notice: "Selected smoke notice."
|
|
3105
|
+
});
|
|
3106
|
+
assert(changeWithNotice.fields.events?.at(-1)?.eventType === "notice_given", "Change smoke did not append notice event.");
|
|
3107
|
+
const decision = await callJsonTool("create_decision", {
|
|
3108
|
+
title: `DX Complete hosted MCP smoke decision ${runId}`,
|
|
3109
|
+
matter: "What informed the selected smoke decision?",
|
|
3110
|
+
initialDecision: {
|
|
3111
|
+
body: "Use the selected smoke input."
|
|
3112
|
+
}
|
|
3113
|
+
});
|
|
3114
|
+
cleanupRecords.push(["decisions", decision._id]);
|
|
3115
|
+
const linkedDecision = await callJsonTool("link_decision_input", {
|
|
3116
|
+
decisionId: decision._id,
|
|
3117
|
+
inputRecordType: "changes",
|
|
3118
|
+
inputId: change._id
|
|
3119
|
+
});
|
|
3120
|
+
assert(linkedDecision.links.some((link) => link.toType === "changes" && link.relationship === "informed_by"), "Change smoke did not link Decision to Change.");
|
|
3121
|
+
const inbound = await callJsonTool("list_linked_records", {
|
|
3122
|
+
recordType: "changes",
|
|
3123
|
+
id: change._id,
|
|
3124
|
+
direction: "inbound",
|
|
3125
|
+
relationship: "informed_by"
|
|
3126
|
+
});
|
|
3127
|
+
assert(inbound.inbound.some((entry) => entry.record._id === decision._id), "Change smoke did not return inbound decision input.");
|
|
3128
|
+
await callJsonTool("link_decision_input", {
|
|
3129
|
+
decisionId: decision._id,
|
|
3130
|
+
inputRecordType: "expectations",
|
|
3131
|
+
inputId: expectation._id
|
|
3132
|
+
});
|
|
3133
|
+
const listedDecisions = await callJsonTool("list_records", { recordType: "decisions", includeArchived: true, limit: 100 });
|
|
3134
|
+
assert(
|
|
3135
|
+
listedDecisions.every(
|
|
3136
|
+
(record) =>
|
|
3137
|
+
Array.isArray(record.fields.entries) &&
|
|
3138
|
+
record.fields.entries.length > 0 &&
|
|
3139
|
+
record.fields.matter &&
|
|
3140
|
+
!Object.hasOwn(record.fields, "question") &&
|
|
3141
|
+
!Object.hasOwn(record.fields, "decision") &&
|
|
3142
|
+
!Object.hasOwn(record.fields, "argumentsConsidered") &&
|
|
3143
|
+
!Object.hasOwn(record.fields, "status")
|
|
3144
|
+
),
|
|
3145
|
+
"Change smoke found a Decision without the ledger shape."
|
|
3146
|
+
);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
async function runTicketsSection(cleanupTickets) {
|
|
3150
|
+
const ticket = await callJsonTool("create_dxcomplete_ticket", {
|
|
3151
|
+
title: `DX Complete hosted MCP smoke ticket ${runId}`,
|
|
3152
|
+
body: "Selected tickets smoke body."
|
|
3153
|
+
});
|
|
3154
|
+
cleanupTickets.push(ticket._id);
|
|
3155
|
+
const listed = await callJsonTool("list_dxcomplete_tickets", { limit: 10 });
|
|
3156
|
+
assert(listed.some((record) => record._id === ticket._id), "Tickets smoke did not list created ticket.");
|
|
3157
|
+
const ticketWithReply = await appendDxcompleteTicketReply(runtime.db, {
|
|
3158
|
+
id: ticket._id,
|
|
3159
|
+
body: "Selected tickets smoke reply.",
|
|
3160
|
+
addressedToActorId: smokeActor.actorId
|
|
3161
|
+
}, smokeCleanupActorId);
|
|
3162
|
+
const reply = ticketWithReply.fields.entries.find((entry) => entry.direction === "dxcomplete_reply");
|
|
3163
|
+
const unread = await callJsonTool("list_unread_dxcomplete_ticket_replies", { limit: 10 });
|
|
3164
|
+
assert(unread.some((entry) => entry.ticketId === ticket._id && entry.replies.some((item) => item.id === reply.id)), "Tickets smoke did not list unread reply.");
|
|
3165
|
+
const read = await callJsonTool("read_dxcomplete_ticket", { id: ticket._id });
|
|
3166
|
+
assert(read.fields.entries.some((entry) => entry.id === reply.id && entry.readAt), "Tickets smoke did not mark reply read.");
|
|
3167
|
+
const archived = await callJsonTool("archive_dxcomplete_ticket", { id: ticket._id });
|
|
3168
|
+
assert(archived.archivedAt, "Tickets smoke did not archive ticket.");
|
|
3169
|
+
cleanupTickets.splice(cleanupTickets.indexOf(ticket._id), 1);
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
async function runJournalSection(cleanupRecords) {
|
|
3173
|
+
const firstNote = await callJsonTool("append_journal_note", {
|
|
3174
|
+
body: `DX Complete hosted MCP smoke journal note one ${runId}`,
|
|
3175
|
+
tag: "smoke"
|
|
3176
|
+
});
|
|
3177
|
+
cleanupRecords.push(["journal_entries", firstNote._id]);
|
|
3178
|
+
assert(firstNote.recordType === "journal_entries", "Journal smoke did not create a journal entry.");
|
|
3179
|
+
assert(firstNote.fields.kind === "note", "Journal smoke note did not store kind note.");
|
|
3180
|
+
assert(/^JRN-\d{4,}$/.test(firstNote.readableId ?? ""), "Journal note did not receive a readable ID.");
|
|
3181
|
+
|
|
3182
|
+
const secondNote = await callJsonTool("append_journal_note", {
|
|
3183
|
+
body: `DX Complete hosted MCP smoke journal note two ${runId}`,
|
|
3184
|
+
tag: "smoke"
|
|
3185
|
+
});
|
|
3186
|
+
cleanupRecords.push(["journal_entries", secondNote._id]);
|
|
3187
|
+
|
|
3188
|
+
const hotBeforeSummary = await callJsonTool("read_journal", { limit: 20 });
|
|
3189
|
+
assert(hotBeforeSummary.readTier === "hot", "Journal default read should return the hot tier.");
|
|
3190
|
+
assert(
|
|
3191
|
+
hotBeforeSummary.entries.some((entry) => entry._id === firstNote._id) &&
|
|
3192
|
+
hotBeforeSummary.entries.some((entry) => entry._id === secondNote._id),
|
|
3193
|
+
"Journal hot read did not include active raw notes."
|
|
3194
|
+
);
|
|
3195
|
+
assert(
|
|
3196
|
+
hotBeforeSummary.compaction?.threshold === 200,
|
|
3197
|
+
"Journal read did not report the fixed compaction threshold."
|
|
3198
|
+
);
|
|
3199
|
+
|
|
3200
|
+
const summary = await callJsonTool("append_journal_summary", {
|
|
3201
|
+
body: `DX Complete hosted MCP smoke journal summary ${runId}`,
|
|
3202
|
+
covers: [firstNote._id, secondNote.readableId],
|
|
3203
|
+
tag: "smoke-summary"
|
|
3204
|
+
});
|
|
3205
|
+
cleanupRecords.push(["journal_entries", summary._id]);
|
|
3206
|
+
assert(summary.fields.kind === "summary", "Journal summary did not store kind summary.");
|
|
3207
|
+
assert(summary.fields.covers?.includes(firstNote._id), "Journal summary did not cover the first note by UUID.");
|
|
3208
|
+
assert(summary.fields.covers?.includes(secondNote._id), "Journal summary did not resolve readable ID covers to UUID.");
|
|
3209
|
+
|
|
3210
|
+
const hotAfterSummary = await callJsonTool("read_journal", { limit: 20 });
|
|
3211
|
+
assert(hotAfterSummary.entries.some((entry) => entry._id === summary._id), "Journal hot read did not include the active summary.");
|
|
3212
|
+
assert(
|
|
3213
|
+
!hotAfterSummary.entries.some((entry) => entry._id === firstNote._id || entry._id === secondNote._id),
|
|
3214
|
+
"Journal hot read included raw notes after they were summarized."
|
|
3215
|
+
);
|
|
3216
|
+
|
|
3217
|
+
const archivedNote = await callJsonTool("get_journal_entry", { id: firstNote._id });
|
|
3218
|
+
assert(archivedNote.archivedAt, "Journal covered note was not archived after summary compaction.");
|
|
3219
|
+
assert(
|
|
3220
|
+
archivedNote.fields.coveredBySummaryId === summary._id,
|
|
3221
|
+
"Journal covered note did not point back to the covering summary."
|
|
3222
|
+
);
|
|
3223
|
+
|
|
3224
|
+
const coldRead = await callJsonTool("read_journal", { includeArchived: true, limit: 20 });
|
|
3225
|
+
assert(coldRead.readTier === "cold", "Journal includeArchived read should identify the cold tier.");
|
|
3226
|
+
assert(coldRead.entries.some((entry) => entry._id === firstNote._id), "Journal cold read did not include archived raw notes.");
|
|
3227
|
+
|
|
3228
|
+
const decision = await callJsonTool("create_decision", {
|
|
3229
|
+
title: `DX Complete hosted MCP smoke journal decision ${runId}`,
|
|
3230
|
+
matter: "Can a journal entry inform a decision?",
|
|
3231
|
+
initialDecision: {
|
|
3232
|
+
body: "Yes."
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
cleanupRecords.push(["decisions", decision._id]);
|
|
3236
|
+
const linkedDecision = await callJsonTool("link_decision_input", {
|
|
3237
|
+
decisionId: decision._id,
|
|
3238
|
+
inputRecordType: "journal_entries",
|
|
3239
|
+
inputId: firstNote._id
|
|
3240
|
+
});
|
|
3241
|
+
assert(
|
|
3242
|
+
linkedDecision.links.some((link) => link.toType === "journal_entries" && link.toId === firstNote._id && link.relationship === "informed_by"),
|
|
3243
|
+
"Journal entry was not accepted as a Decision input."
|
|
3244
|
+
);
|
|
3245
|
+
const inbound = await callJsonTool("list_linked_records", {
|
|
3246
|
+
recordType: "journal_entries",
|
|
3247
|
+
id: firstNote._id,
|
|
3248
|
+
direction: "inbound",
|
|
3249
|
+
relationship: "informed_by",
|
|
3250
|
+
includeArchived: true
|
|
3251
|
+
});
|
|
3252
|
+
assert(inbound.inbound.some((entry) => entry.record._id === decision._id), "Journal entry did not return inbound Decision links.");
|
|
3253
|
+
|
|
3254
|
+
await expectToolError("create_record", {
|
|
3255
|
+
recordType: "journal_entries",
|
|
3256
|
+
title: `DX Complete hosted MCP invalid journal entry ${runId}`,
|
|
3257
|
+
fields: { kind: "note", body: "Direct journal mutation should fail." }
|
|
3258
|
+
}, "append_journal_note");
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
async function cleanupSelectedSmokeArtifacts(records, tickets) {
|
|
3262
|
+
if (runtime && workspaceConfig?.workspaceId) {
|
|
3263
|
+
for (const [recordType, id] of [...records].reverse()) {
|
|
3264
|
+
await archiveRecord(runtime.db, {
|
|
3265
|
+
recordType,
|
|
3266
|
+
id,
|
|
3267
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
3268
|
+
reason: "Hosted MCP smoke cleanup"
|
|
3269
|
+
}, smokeCleanupActorId).catch(() => undefined);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
for (const id of tickets) {
|
|
3274
|
+
if (smokeActor && runtime) {
|
|
3275
|
+
await archiveDxcompleteTicket(runtime.db, { id }, smokeActor).catch(() => undefined);
|
|
3276
|
+
continue;
|
|
3277
|
+
}
|
|
3278
|
+
if (runtime) {
|
|
3279
|
+
await archiveDxcompleteTicketById(runtime.db, id).catch(() => undefined);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
if (runtime && createdServiceClientId) {
|
|
3284
|
+
await runtime.db.collection("workspace_service_clients").deleteOne({ _id: createdServiceClientId }).catch(() => undefined);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
async function timedSmokeSection(timings, name, fn) {
|
|
3289
|
+
const startedAt = Date.now();
|
|
3290
|
+
try {
|
|
3291
|
+
const result = await fn();
|
|
3292
|
+
const seconds = elapsedSeconds(startedAt);
|
|
3293
|
+
timings.push({ name, seconds });
|
|
3294
|
+
smokeStep(`${name} ok (${seconds}s)`);
|
|
3295
|
+
return result;
|
|
3296
|
+
} catch (error) {
|
|
3297
|
+
const seconds = elapsedSeconds(startedAt);
|
|
3298
|
+
smokeStep(`${name} failed after ${seconds}s`);
|
|
3299
|
+
throw error;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
async function callJsonTool(name, args) {
|
|
3304
|
+
const result = await withTimeout(client.callTool({ name, arguments: args }), smokeTimeoutMs, `${name} timed out.`);
|
|
3305
|
+
const text = result.content?.find((entry) => entry.type === "text")?.text;
|
|
3306
|
+
assert(typeof text === "string", `${name} did not return text content.`);
|
|
3307
|
+
if (result.isError) {
|
|
3308
|
+
throw new Error(`${name} failed: ${text}`);
|
|
3309
|
+
}
|
|
3310
|
+
return JSON.parse(text);
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function expectToolError(name, args, expectedMessagePart) {
|
|
3314
|
+
const result = await withTimeout(client.callTool({ name, arguments: args }), smokeTimeoutMs, `${name} timed out.`);
|
|
3315
|
+
const text = result.content?.find((entry) => entry.type === "text")?.text ?? "";
|
|
3316
|
+
assert(result.isError === true, `${name} was expected to fail.`);
|
|
3317
|
+
assert(
|
|
3318
|
+
text.includes(expectedMessagePart),
|
|
3319
|
+
`${name} error did not include "${expectedMessagePart}". Received: ${text}`
|
|
3320
|
+
);
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function readableIdNumber(readableId) {
|
|
3324
|
+
assert(typeof readableId === "string" && /^[A-Z]{3}-\d{4,}$/.test(readableId), `Invalid readable ID: ${readableId}`);
|
|
3325
|
+
return Number(readableId.split("-")[1]);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
async function assertReadableIdsContiguous() {
|
|
3329
|
+
if (!runtime || !workspaceConfig?.workspaceId) {
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
const readableIdTypes = {
|
|
3334
|
+
statements: "STM",
|
|
3335
|
+
journal_entries: "JRN",
|
|
3336
|
+
environments: "ENV",
|
|
3337
|
+
components: "CMP",
|
|
3338
|
+
expectations: "EXP",
|
|
3339
|
+
requirements: "REQ",
|
|
3340
|
+
tasks: "TSK",
|
|
3341
|
+
commitments: "CMT",
|
|
3342
|
+
deferrals: "DFR",
|
|
3343
|
+
decisions: "DEC",
|
|
3344
|
+
changes: "CHG",
|
|
3345
|
+
risks: "RSK",
|
|
3346
|
+
estimates: "EST",
|
|
3347
|
+
benefits: "BFT"
|
|
3348
|
+
};
|
|
3349
|
+
|
|
3350
|
+
for (const [recordType, prefix] of Object.entries(readableIdTypes)) {
|
|
3351
|
+
const records = await runtime.db
|
|
3352
|
+
.collection(recordType)
|
|
3353
|
+
.find({ workspaceId: workspaceConfig.workspaceId }, { projection: { readableId: 1, createdAt: 1, _id: 1 } })
|
|
3354
|
+
.sort({ readableId: 1 })
|
|
3355
|
+
.toArray();
|
|
3356
|
+
|
|
3357
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
3358
|
+
const expected = `${prefix}-${String(index + 1).padStart(4, "0")}`;
|
|
3359
|
+
assert(records[index].readableId === expected, `${recordType} readable IDs are not contiguous at ${expected}.`);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
async function archiveCreatedRecord(recordType, id) {
|
|
3365
|
+
if (!id) {
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
if (client) {
|
|
3370
|
+
try {
|
|
3371
|
+
await callJsonTool("archive_record", { recordType, id });
|
|
3372
|
+
return;
|
|
3373
|
+
} catch {
|
|
3374
|
+
// Fall back to direct cleanup when the MCP client is unavailable or already closing.
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
if (!runtime || !workspaceConfig?.workspaceId) {
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
await archiveRecord(runtime.db, {
|
|
3383
|
+
recordType,
|
|
3384
|
+
id,
|
|
3385
|
+
workspaceId: workspaceConfig.workspaceId,
|
|
3386
|
+
reason: "Hosted MCP smoke cleanup"
|
|
3387
|
+
}, smokeCleanupActorId).catch(() => undefined);
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
async function archiveCreatedTicket(id) {
|
|
3391
|
+
if (!id) {
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
if (client) {
|
|
3396
|
+
try {
|
|
3397
|
+
await callJsonTool("archive_dxcomplete_ticket", { id });
|
|
3398
|
+
return;
|
|
3399
|
+
} catch {
|
|
3400
|
+
// Fall back to direct cleanup when the MCP client is unavailable or already closing.
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
if (!runtime) {
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
if (smokeActor) {
|
|
3409
|
+
await archiveDxcompleteTicket(runtime.db, { id }, smokeActor).catch(() => undefined);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
await archiveDxcompleteTicketById(runtime.db, id);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
async function archiveStaleSmokeArtifacts(db, workspaceId) {
|
|
3417
|
+
let archivedCount = 0;
|
|
3418
|
+
|
|
3419
|
+
for (const recordType of smokeRecordTypes) {
|
|
3420
|
+
const records = await db.collection(recordType)
|
|
3421
|
+
.find({
|
|
3422
|
+
workspaceId,
|
|
3423
|
+
archivedAt: { $exists: false },
|
|
3424
|
+
title: smokeTitlePattern
|
|
3425
|
+
}, { projection: { _id: 1 } })
|
|
3426
|
+
.toArray();
|
|
3427
|
+
|
|
3428
|
+
for (const record of records) {
|
|
3429
|
+
try {
|
|
3430
|
+
await archiveRecord(db, {
|
|
3431
|
+
recordType,
|
|
3432
|
+
id: record._id,
|
|
3433
|
+
workspaceId,
|
|
3434
|
+
reason: "Hosted MCP smoke cleanup"
|
|
3435
|
+
}, smokeCleanupActorId);
|
|
3436
|
+
archivedCount += 1;
|
|
3437
|
+
} catch {
|
|
3438
|
+
// Best-effort stale cleanup should not block the smoke test itself.
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
const now = new Date().toISOString();
|
|
3444
|
+
const ticketCleanupResult = await db.collection(DXCOMPLETE_TICKET_COLLECTION_NAME).updateMany(
|
|
3445
|
+
{
|
|
3446
|
+
archivedAt: { $exists: false },
|
|
3447
|
+
$or: [
|
|
3448
|
+
{ title: smokeTitlePattern },
|
|
3449
|
+
{ "fields.ownerActorId": /^google:http-smoke-/ }
|
|
3450
|
+
]
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
$set: {
|
|
3454
|
+
archivedAt: now,
|
|
3455
|
+
archivedBy: smokeCleanupActorId,
|
|
3456
|
+
archiveReason: "Hosted MCP smoke cleanup",
|
|
3457
|
+
updatedAt: now,
|
|
3458
|
+
updatedBy: smokeCleanupActorId
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
);
|
|
3462
|
+
archivedCount += ticketCleanupResult.modifiedCount;
|
|
3463
|
+
|
|
3464
|
+
if (archivedCount > 0) {
|
|
3465
|
+
smokeStep(`archived ${archivedCount} stale smoke artifact${archivedCount === 1 ? "" : "s"}`);
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
async function archiveDxcompleteTicketById(db, id) {
|
|
3470
|
+
const now = new Date().toISOString();
|
|
3471
|
+
await db.collection(DXCOMPLETE_TICKET_COLLECTION_NAME).updateOne(
|
|
3472
|
+
{
|
|
3473
|
+
_id: id,
|
|
3474
|
+
archivedAt: { $exists: false }
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
$set: {
|
|
3478
|
+
archivedAt: now,
|
|
3479
|
+
archivedBy: smokeCleanupActorId,
|
|
3480
|
+
archiveReason: "Hosted MCP smoke cleanup",
|
|
3481
|
+
updatedAt: now,
|
|
3482
|
+
updatedBy: smokeCleanupActorId
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
);
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
3489
|
+
let timeout;
|
|
3490
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
3491
|
+
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
3492
|
+
});
|
|
3493
|
+
|
|
3494
|
+
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeout));
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = smokeTimeoutMs) {
|
|
3498
|
+
const controller = new AbortController();
|
|
3499
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
3500
|
+
|
|
3501
|
+
try {
|
|
3502
|
+
return await fetch(url, {
|
|
3503
|
+
...options,
|
|
3504
|
+
signal: options.signal ?? controller.signal
|
|
3505
|
+
});
|
|
3506
|
+
} catch (error) {
|
|
3507
|
+
if (error?.name === "AbortError") {
|
|
3508
|
+
throw new Error(`fetch timed out: ${String(url)}`);
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
throw error;
|
|
3512
|
+
} finally {
|
|
3513
|
+
clearTimeout(timeout);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function listen(httpServer) {
|
|
3518
|
+
return new Promise((resolve, reject) => {
|
|
3519
|
+
httpServer.once("error", reject);
|
|
3520
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
3521
|
+
const address = httpServer.address();
|
|
3522
|
+
if (!address || typeof address === "string") {
|
|
3523
|
+
reject(new Error("Unable to determine HTTP smoke server address."));
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3526
|
+
resolve(`http://127.0.0.1:${address.port}`);
|
|
3527
|
+
});
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function closeServer(httpServer) {
|
|
3532
|
+
if (!httpServer?.listening) {
|
|
3533
|
+
return Promise.resolve();
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
return new Promise((resolve, reject) => {
|
|
3537
|
+
httpServer.closeIdleConnections?.();
|
|
3538
|
+
httpServer.closeAllConnections?.();
|
|
3539
|
+
const timeout = setTimeout(() => resolve(), 2_000);
|
|
3540
|
+
httpServer.close((error) => {
|
|
3541
|
+
clearTimeout(timeout);
|
|
3542
|
+
return error ? reject(error) : resolve();
|
|
3543
|
+
});
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
function assert(condition, message) {
|
|
3548
|
+
if (!condition) {
|
|
3549
|
+
throw new Error(message);
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
function elapsedSeconds(startedAt) {
|
|
3554
|
+
return Number(((Date.now() - startedAt) / 1000).toFixed(2));
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
function smokeStep(message) {
|
|
3558
|
+
const now = Date.now();
|
|
3559
|
+
const deltaSeconds = ((now - lastSmokeStepAt) / 1000).toFixed(2);
|
|
3560
|
+
const totalSeconds = ((now - smokeStartedAt) / 1000).toFixed(2);
|
|
3561
|
+
lastSmokeStepAt = now;
|
|
3562
|
+
console.error(`[smoke:mcp:http +${deltaSeconds}s total=${totalSeconds}s] ${message}`);
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function assertEstimateAmount(actual, expected, message) {
|
|
3566
|
+
assert(
|
|
3567
|
+
actual?.min === expected.min &&
|
|
3568
|
+
actual?.expected === expected.expected &&
|
|
3569
|
+
actual?.max === expected.max,
|
|
3570
|
+
`${message} Received: ${JSON.stringify(actual)}`
|
|
3571
|
+
);
|
|
3572
|
+
}
|