@synth-deploy/server 1.0.6 → 1.2.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/dist/agent/envoy-client.d.ts +65 -15
- package/dist/agent/envoy-client.d.ts.map +1 -1
- package/dist/agent/envoy-client.js +58 -8
- package/dist/agent/envoy-client.js.map +1 -1
- package/dist/agent/stale-deployment-detector.js +1 -1
- package/dist/agent/stale-deployment-detector.js.map +1 -1
- package/dist/agent/synth-agent.d.ts +7 -5
- package/dist/agent/synth-agent.d.ts.map +1 -1
- package/dist/agent/synth-agent.js +59 -50
- package/dist/agent/synth-agent.js.map +1 -1
- package/dist/alert-webhooks/alert-parsers.d.ts +21 -0
- package/dist/alert-webhooks/alert-parsers.d.ts.map +1 -0
- package/dist/alert-webhooks/alert-parsers.js +184 -0
- package/dist/alert-webhooks/alert-parsers.js.map +1 -0
- package/dist/api/agent.d.ts +0 -6
- package/dist/api/agent.d.ts.map +1 -1
- package/dist/api/agent.js +6 -459
- package/dist/api/agent.js.map +1 -1
- package/dist/api/alert-webhooks.d.ts +13 -0
- package/dist/api/alert-webhooks.d.ts.map +1 -0
- package/dist/api/alert-webhooks.js +279 -0
- package/dist/api/alert-webhooks.js.map +1 -0
- package/dist/api/envoy-reports.js +2 -2
- package/dist/api/envoy-reports.js.map +1 -1
- package/dist/api/envoys.js +1 -1
- package/dist/api/envoys.js.map +1 -1
- package/dist/api/fleet.d.ts.map +1 -1
- package/dist/api/fleet.js +14 -15
- package/dist/api/fleet.js.map +1 -1
- package/dist/api/graph.js +3 -3
- package/dist/api/graph.js.map +1 -1
- package/dist/api/operations.d.ts +7 -0
- package/dist/api/operations.d.ts.map +1 -0
- package/dist/api/operations.js +1900 -0
- package/dist/api/operations.js.map +1 -0
- package/dist/api/partitions.js +1 -1
- package/dist/api/partitions.js.map +1 -1
- package/dist/api/schemas.d.ts +434 -133
- package/dist/api/schemas.d.ts.map +1 -1
- package/dist/api/schemas.js +53 -25
- package/dist/api/schemas.js.map +1 -1
- package/dist/api/system.d.ts.map +1 -1
- package/dist/api/system.js +22 -21
- package/dist/api/system.js.map +1 -1
- package/dist/artifact-analyzer.js +2 -2
- package/dist/artifact-analyzer.js.map +1 -1
- package/dist/fleet/fleet-executor.js +3 -3
- package/dist/fleet/fleet-executor.js.map +1 -1
- package/dist/graph/graph-executor.d.ts.map +1 -1
- package/dist/graph/graph-executor.js +18 -4
- package/dist/graph/graph-executor.js.map +1 -1
- package/dist/index.js +89 -61
- package/dist/index.js.map +1 -1
- package/dist/mcp/resources.js +3 -3
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +2 -9
- package/dist/mcp/tools.js.map +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/auth.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/envoy-client.ts +111 -19
- package/src/agent/stale-deployment-detector.ts +1 -1
- package/src/agent/synth-agent.ts +76 -56
- package/src/alert-webhooks/alert-parsers.ts +291 -0
- package/src/api/agent.ts +9 -528
- package/src/api/alert-webhooks.ts +354 -0
- package/src/api/envoy-reports.ts +2 -2
- package/src/api/envoys.ts +1 -1
- package/src/api/fleet.ts +14 -15
- package/src/api/graph.ts +3 -3
- package/src/api/operations.ts +2260 -0
- package/src/api/partitions.ts +1 -1
- package/src/api/schemas.ts +59 -27
- package/src/api/system.ts +23 -21
- package/src/artifact-analyzer.ts +2 -2
- package/src/fleet/fleet-executor.ts +3 -3
- package/src/graph/graph-executor.ts +18 -4
- package/src/index.ts +91 -61
- package/src/mcp/resources.ts +3 -3
- package/src/mcp/tools.ts +5 -9
- package/src/middleware/auth.ts +1 -1
- package/tests/agent-mode.test.ts +5 -376
- package/tests/api-handlers.test.ts +27 -27
- package/tests/composite-operations.test.ts +557 -0
- package/tests/decision-diary.test.ts +62 -63
- package/tests/diary-reader.test.ts +14 -18
- package/tests/mcp-tools.test.ts +1 -1
- package/tests/orchestration.test.ts +34 -30
- package/tests/partition-isolation.test.ts +4 -9
- package/tests/rbac-enforcement.test.ts +8 -8
- package/tests/ui-journey.test.ts +9 -9
- package/dist/api/deployments.d.ts +0 -11
- package/dist/api/deployments.d.ts.map +0 -1
- package/dist/api/deployments.js +0 -1098
- package/dist/api/deployments.js.map +0 -1
- package/src/api/deployments.ts +0 -1347
package/tests/agent-mode.test.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
2
|
import Fastify from "fastify";
|
|
3
3
|
import type { FastifyInstance } from "fastify";
|
|
4
|
-
import { DecisionDebrief, PartitionStore, EnvironmentStore, ArtifactStore, SettingsStore, TelemetryStore
|
|
5
|
-
import type { LlmResult } from "@synth-deploy/core";
|
|
4
|
+
import { DecisionDebrief, PartitionStore, EnvironmentStore, ArtifactStore, SettingsStore, TelemetryStore } from "@synth-deploy/core";
|
|
6
5
|
import { SynthAgent, InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
|
|
7
|
-
import {
|
|
6
|
+
import { registerOperationRoutes } from "../src/api/operations.js";
|
|
8
7
|
import { registerPartitionRoutes } from "../src/api/partitions.js";
|
|
9
8
|
import { registerEnvironmentRoutes } from "../src/api/environments.js";
|
|
10
9
|
import { registerArtifactRoutes } from "../src/api/artifacts.js";
|
|
11
|
-
import { registerAgentRoutes
|
|
10
|
+
import { registerAgentRoutes } from "../src/api/agent.js";
|
|
12
11
|
import { registerSettingsRoutes } from "../src/api/settings.js";
|
|
13
12
|
|
|
14
13
|
// ---------------------------------------------------------------------------
|
|
@@ -67,7 +66,7 @@ beforeAll(async () => {
|
|
|
67
66
|
|
|
68
67
|
app = Fastify();
|
|
69
68
|
addMockAuth(app);
|
|
70
|
-
|
|
69
|
+
registerOperationRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
|
|
71
70
|
registerPartitionRoutes(app, partitions, deployments, diary, telemetry);
|
|
72
71
|
registerEnvironmentRoutes(app, environments, deployments, telemetry);
|
|
73
72
|
registerArtifactRoutes(app, artifactStore, telemetry);
|
|
@@ -121,7 +120,7 @@ async function deployViaHttp(
|
|
|
121
120
|
) {
|
|
122
121
|
return server.inject({
|
|
123
122
|
method: "POST",
|
|
124
|
-
url: "/api/
|
|
123
|
+
url: "/api/operations",
|
|
125
124
|
payload: {
|
|
126
125
|
artifactId: params.artifactId,
|
|
127
126
|
environmentId: params.environmentId,
|
|
@@ -164,373 +163,3 @@ describe("Agent mode — deployment context", () => {
|
|
|
164
163
|
});
|
|
165
164
|
|
|
166
165
|
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
// Input sanitization tests
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
describe("Agent mode — input sanitization", () => {
|
|
172
|
-
it("strips control characters from intent", () => {
|
|
173
|
-
const input = "Deploy\x01\x02\x03\x07 web-app\x0B\x0C\x0E v1.0.0";
|
|
174
|
-
const result = sanitizeUserInput(input);
|
|
175
|
-
// Control characters should be removed
|
|
176
|
-
expect(result).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
|
|
177
|
-
// Printable content should remain
|
|
178
|
-
expect(result).toContain("Deploy");
|
|
179
|
-
expect(result).toContain("web-app");
|
|
180
|
-
expect(result).toContain("v1.0.0");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("truncates long inputs to 1000 characters", () => {
|
|
184
|
-
const longInput = "a".repeat(2000);
|
|
185
|
-
const result = sanitizeUserInput(longInput);
|
|
186
|
-
expect(result.length).toBe(1000);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("escapes XML tags in user input", () => {
|
|
190
|
-
const input = "<script>alert('xss')</script>";
|
|
191
|
-
const result = sanitizeUserInput(input);
|
|
192
|
-
expect(result).not.toContain("<script>");
|
|
193
|
-
expect(result).not.toContain("</script>");
|
|
194
|
-
expect(result).toContain("<script>");
|
|
195
|
-
expect(result).toContain("</script>");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("validates semver version format", () => {
|
|
199
|
-
// Valid formats
|
|
200
|
-
expect(validateExtractedVersion("1.2.3")).toBe(true);
|
|
201
|
-
expect(validateExtractedVersion("1.2.3-beta.1")).toBe(true);
|
|
202
|
-
expect(validateExtractedVersion("0.0.1")).toBe(true);
|
|
203
|
-
expect(validateExtractedVersion("10.20.30-alpha")).toBe(true);
|
|
204
|
-
|
|
205
|
-
// Invalid formats
|
|
206
|
-
expect(validateExtractedVersion("not-a-version")).toBe(false);
|
|
207
|
-
expect(validateExtractedVersion("1.2")).toBe(false);
|
|
208
|
-
expect(validateExtractedVersion("../../../etc/passwd")).toBe(false);
|
|
209
|
-
expect(validateExtractedVersion("v1.2.3")).toBe(false);
|
|
210
|
-
expect(validateExtractedVersion("")).toBe(false);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("validates variable key format", () => {
|
|
214
|
-
// Valid keys
|
|
215
|
-
const valid = validateExtractedVariables({ APP_ENV: "production", DB_HOST: "localhost" });
|
|
216
|
-
expect(valid).toHaveProperty("APP_ENV", "production");
|
|
217
|
-
expect(valid).toHaveProperty("DB_HOST", "localhost");
|
|
218
|
-
|
|
219
|
-
// Invalid keys should be excluded
|
|
220
|
-
const invalid = validateExtractedVariables({
|
|
221
|
-
"../../path": "value",
|
|
222
|
-
"key with spaces": "value",
|
|
223
|
-
"": "value",
|
|
224
|
-
"123invalid": "value",
|
|
225
|
-
});
|
|
226
|
-
expect(Object.keys(invalid)).toHaveLength(0);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("rejects variables with values exceeding 500 chars", () => {
|
|
230
|
-
const longValue = "x".repeat(600);
|
|
231
|
-
const result = validateExtractedVariables({ VALID_KEY: longValue, SHORT_KEY: "ok" });
|
|
232
|
-
expect(result).not.toHaveProperty("VALID_KEY");
|
|
233
|
-
expect(result).toHaveProperty("SHORT_KEY", "ok");
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
// LLM-powered query classification tests
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
|
|
242
|
-
describe("Agent mode — LLM query classification", () => {
|
|
243
|
-
let qApp: FastifyInstance;
|
|
244
|
-
let qDiary: DecisionDebrief;
|
|
245
|
-
let qPartitions: PartitionStore;
|
|
246
|
-
let qEnvironments: EnvironmentStore;
|
|
247
|
-
let qDeployments: InMemoryDeploymentStore;
|
|
248
|
-
let qArtifactStore: ArtifactStore;
|
|
249
|
-
let qSettings: SettingsStore;
|
|
250
|
-
let qTelemetry: TelemetryStore;
|
|
251
|
-
let qAgent: SynthAgent;
|
|
252
|
-
let qMockLlm: LlmClient;
|
|
253
|
-
|
|
254
|
-
let qArtifactId: string;
|
|
255
|
-
let qPartitionId: string;
|
|
256
|
-
let qProdEnvId: string;
|
|
257
|
-
let qStagingEnvId: string;
|
|
258
|
-
|
|
259
|
-
// Track what classify() should return for query classification
|
|
260
|
-
let qClassifyResponse: LlmResult;
|
|
261
|
-
|
|
262
|
-
beforeAll(async () => {
|
|
263
|
-
qDiary = new DecisionDebrief();
|
|
264
|
-
qPartitions = new PartitionStore();
|
|
265
|
-
qEnvironments = new EnvironmentStore();
|
|
266
|
-
qDeployments = new InMemoryDeploymentStore();
|
|
267
|
-
qArtifactStore = new ArtifactStore();
|
|
268
|
-
qSettings = new SettingsStore();
|
|
269
|
-
qTelemetry = new TelemetryStore();
|
|
270
|
-
qAgent = new SynthAgent(
|
|
271
|
-
qDiary, qDeployments, qArtifactStore, qEnvironments, qPartitions,
|
|
272
|
-
undefined, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
qMockLlm = new LlmClient(qDiary, "command", { apiKey: "test-key" });
|
|
276
|
-
qMockLlm.classify = async () => qClassifyResponse;
|
|
277
|
-
qMockLlm.isAvailable = () => true;
|
|
278
|
-
|
|
279
|
-
qApp = Fastify();
|
|
280
|
-
addMockAuth(qApp);
|
|
281
|
-
registerDeploymentRoutes(qApp, qDeployments, qDiary, qPartitions, qEnvironments, qArtifactStore, qSettings, qTelemetry);
|
|
282
|
-
registerPartitionRoutes(qApp, qPartitions, qDeployments, qDiary, qTelemetry);
|
|
283
|
-
registerEnvironmentRoutes(qApp, qEnvironments, qDeployments, qTelemetry);
|
|
284
|
-
registerArtifactRoutes(qApp, qArtifactStore, qTelemetry);
|
|
285
|
-
registerSettingsRoutes(qApp, qSettings, qTelemetry);
|
|
286
|
-
registerAgentRoutes(qApp, qAgent, qPartitions, qEnvironments, qArtifactStore, qDeployments, qDiary, qSettings, qMockLlm);
|
|
287
|
-
|
|
288
|
-
await qApp.ready();
|
|
289
|
-
|
|
290
|
-
// Seed test data
|
|
291
|
-
const envRes = await qApp.inject({
|
|
292
|
-
method: "POST",
|
|
293
|
-
url: "/api/environments",
|
|
294
|
-
payload: { name: "production", variables: { APP_ENV: "production" } },
|
|
295
|
-
});
|
|
296
|
-
qProdEnvId = JSON.parse(envRes.payload).environment.id;
|
|
297
|
-
|
|
298
|
-
const stagingRes = await qApp.inject({
|
|
299
|
-
method: "POST",
|
|
300
|
-
url: "/api/environments",
|
|
301
|
-
payload: { name: "staging", variables: { APP_ENV: "staging" } },
|
|
302
|
-
});
|
|
303
|
-
qStagingEnvId = JSON.parse(stagingRes.payload).environment.id;
|
|
304
|
-
|
|
305
|
-
const artifactRes = await qApp.inject({
|
|
306
|
-
method: "POST",
|
|
307
|
-
url: "/api/artifacts",
|
|
308
|
-
payload: { name: "web-app", type: "nodejs" },
|
|
309
|
-
});
|
|
310
|
-
qArtifactId = JSON.parse(artifactRes.payload).artifact.id;
|
|
311
|
-
|
|
312
|
-
const partRes = await qApp.inject({
|
|
313
|
-
method: "POST",
|
|
314
|
-
url: "/api/partitions",
|
|
315
|
-
payload: { name: "Acme Corp" },
|
|
316
|
-
});
|
|
317
|
-
qPartitionId = JSON.parse(partRes.payload).partition.id;
|
|
318
|
-
|
|
319
|
-
// Create a deployment so data queries have something to find
|
|
320
|
-
await deployViaHttp(qApp, { artifactId: qArtifactId, partitionId: qPartitionId, environmentId: qProdEnvId, version: "1.0.0" });
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("navigate action: resolves 'show partition Acme Corp' to partition-detail", async () => {
|
|
324
|
-
qClassifyResponse = {
|
|
325
|
-
ok: true,
|
|
326
|
-
text: JSON.stringify({
|
|
327
|
-
action: "navigate",
|
|
328
|
-
view: "partition-detail",
|
|
329
|
-
params: { id: "Acme Corp" },
|
|
330
|
-
title: "Acme Corp",
|
|
331
|
-
}),
|
|
332
|
-
model: "claude-haiku-4-5-20251001",
|
|
333
|
-
responseTimeMs: 80,
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const res = await qApp.inject({
|
|
337
|
-
method: "POST",
|
|
338
|
-
url: "/api/agent/query",
|
|
339
|
-
payload: { query: "show partition Acme Corp" },
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
expect(res.statusCode).toBe(200);
|
|
343
|
-
const result = JSON.parse(res.payload);
|
|
344
|
-
expect(result.action).toBe("navigate");
|
|
345
|
-
expect(result.view).toBe("partition-detail");
|
|
346
|
-
expect(result.params.id).toBe(qPartitionId);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it("data action: resolves 'recent deployments' to deployment-list", async () => {
|
|
350
|
-
qClassifyResponse = {
|
|
351
|
-
ok: true,
|
|
352
|
-
text: JSON.stringify({
|
|
353
|
-
action: "data",
|
|
354
|
-
view: "deployment-list",
|
|
355
|
-
params: {},
|
|
356
|
-
title: "Recent Deployments",
|
|
357
|
-
}),
|
|
358
|
-
model: "claude-haiku-4-5-20251001",
|
|
359
|
-
responseTimeMs: 60,
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
const res = await qApp.inject({
|
|
363
|
-
method: "POST",
|
|
364
|
-
url: "/api/agent/query",
|
|
365
|
-
payload: { query: "recent deployments" },
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
expect(res.statusCode).toBe(200);
|
|
369
|
-
const result = JSON.parse(res.payload);
|
|
370
|
-
expect(result.action).toBe("data");
|
|
371
|
-
expect(result.view).toBe("deployment-list");
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("create action: returns create intent for UI confirmation", async () => {
|
|
375
|
-
qClassifyResponse = {
|
|
376
|
-
ok: true,
|
|
377
|
-
text: JSON.stringify({
|
|
378
|
-
action: "create",
|
|
379
|
-
view: "partition-list",
|
|
380
|
-
params: { name: "New Corp" },
|
|
381
|
-
title: "Create Partition",
|
|
382
|
-
}),
|
|
383
|
-
model: "claude-haiku-4-5-20251001",
|
|
384
|
-
responseTimeMs: 90,
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
const res = await qApp.inject({
|
|
388
|
-
method: "POST",
|
|
389
|
-
url: "/api/agent/query",
|
|
390
|
-
payload: { query: "create partition New Corp" },
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
expect(res.statusCode).toBe(200);
|
|
394
|
-
const result = JSON.parse(res.payload);
|
|
395
|
-
// After #63, create actions are returned as-is for UI confirmation
|
|
396
|
-
expect(result.action).toBe("create");
|
|
397
|
-
expect(result.view).toBe("partition-list");
|
|
398
|
-
expect(result.params.name).toBe("New Corp");
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it("falls back to regex when LLM returns invalid JSON", async () => {
|
|
402
|
-
qClassifyResponse = {
|
|
403
|
-
ok: true,
|
|
404
|
-
text: "This is not valid JSON, just random text",
|
|
405
|
-
model: "claude-haiku-4-5-20251001",
|
|
406
|
-
responseTimeMs: 100,
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
const res = await qApp.inject({
|
|
410
|
-
method: "POST",
|
|
411
|
-
url: "/api/agent/query",
|
|
412
|
-
payload: { query: "show partition Acme Corp" },
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
expect(res.statusCode).toBe(200);
|
|
416
|
-
const result = JSON.parse(res.payload);
|
|
417
|
-
// Regex fallback should still classify the query — it matches partition name + "show"
|
|
418
|
-
expect(result.action).toBe("navigate");
|
|
419
|
-
expect(result.view).toBe("partition-detail");
|
|
420
|
-
expect(result.params.id).toBe(qPartitionId);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it("falls back to regex when LLM returns hallucinated entity names", async () => {
|
|
424
|
-
qClassifyResponse = {
|
|
425
|
-
ok: true,
|
|
426
|
-
text: JSON.stringify({
|
|
427
|
-
action: "navigate",
|
|
428
|
-
view: "partition-detail",
|
|
429
|
-
params: { id: "Nonexistent Partition" },
|
|
430
|
-
title: "Nonexistent Partition",
|
|
431
|
-
}),
|
|
432
|
-
model: "claude-haiku-4-5-20251001",
|
|
433
|
-
responseTimeMs: 90,
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
const res = await qApp.inject({
|
|
437
|
-
method: "POST",
|
|
438
|
-
url: "/api/agent/query",
|
|
439
|
-
payload: { query: "show partition Acme Corp" },
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
expect(res.statusCode).toBe(200);
|
|
443
|
-
const result = JSON.parse(res.payload);
|
|
444
|
-
// classifyQueryWithLlm validates the partition name and returns null for unknown names,
|
|
445
|
-
// causing fallback to regex which finds "Acme Corp" in the query
|
|
446
|
-
expect(result.action).toBe("navigate");
|
|
447
|
-
expect(result.view).toBe("partition-detail");
|
|
448
|
-
expect(result.params.id).toBe(qPartitionId);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it("falls back to regex when LLM call fails", async () => {
|
|
452
|
-
qClassifyResponse = {
|
|
453
|
-
ok: false,
|
|
454
|
-
fallback: true,
|
|
455
|
-
reason: "LLM rate limit exceeded (20 calls/min)",
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const res = await qApp.inject({
|
|
459
|
-
method: "POST",
|
|
460
|
-
url: "/api/agent/query",
|
|
461
|
-
payload: { query: "show all deployments" },
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
expect(res.statusCode).toBe(200);
|
|
465
|
-
const result = JSON.parse(res.payload);
|
|
466
|
-
// Regex fallback detects "deployments" keyword → returns inline markdown table
|
|
467
|
-
expect(result.action).toBe("answer");
|
|
468
|
-
expect(result.content).toBeDefined();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("records debrief entry for LLM-classified queries", async () => {
|
|
472
|
-
const existingIds = new Set(qDiary.getRecent(200).map((e) => e.id));
|
|
473
|
-
|
|
474
|
-
qClassifyResponse = {
|
|
475
|
-
ok: true,
|
|
476
|
-
text: JSON.stringify({
|
|
477
|
-
action: "data",
|
|
478
|
-
view: "deployment-list",
|
|
479
|
-
params: {},
|
|
480
|
-
title: "Deployments",
|
|
481
|
-
}),
|
|
482
|
-
model: "claude-haiku-4-5-20251001",
|
|
483
|
-
responseTimeMs: 50,
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
await qApp.inject({
|
|
487
|
-
method: "POST",
|
|
488
|
-
url: "/api/agent/query",
|
|
489
|
-
payload: { query: "show all deployments" },
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
const allEntries = qDiary.getRecent(200);
|
|
493
|
-
const newEntries = allEntries.filter((e) => !existingIds.has(e.id));
|
|
494
|
-
const queryEntry = newEntries.find(
|
|
495
|
-
(e) => e.decisionType === "system" && e.decision.includes("Canvas query"),
|
|
496
|
-
);
|
|
497
|
-
expect(queryEntry).toBeDefined();
|
|
498
|
-
expect(queryEntry!.decision).toContain("data");
|
|
499
|
-
expect(queryEntry!.decision).toContain("deployment-list");
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it("returns 400 for empty query", async () => {
|
|
503
|
-
const res = await qApp.inject({
|
|
504
|
-
method: "POST",
|
|
505
|
-
url: "/api/agent/query",
|
|
506
|
-
payload: { query: "" },
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
expect(res.statusCode).toBe(400);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("handles LLM response missing action field by falling back to regex", async () => {
|
|
513
|
-
qClassifyResponse = {
|
|
514
|
-
ok: true,
|
|
515
|
-
text: JSON.stringify({
|
|
516
|
-
view: "deployment-list",
|
|
517
|
-
params: {},
|
|
518
|
-
}),
|
|
519
|
-
model: "claude-haiku-4-5-20251001",
|
|
520
|
-
responseTimeMs: 100,
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
const res = await qApp.inject({
|
|
524
|
-
method: "POST",
|
|
525
|
-
url: "/api/agent/query",
|
|
526
|
-
payload: { query: "recent deployments" },
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
expect(res.statusCode).toBe(200);
|
|
530
|
-
const result = JSON.parse(res.payload);
|
|
531
|
-
// Missing action field → classifyQueryWithLlm returns null → regex fallback
|
|
532
|
-
// "deployments" matches the deployment list pattern → returns inline markdown table
|
|
533
|
-
expect(result.action).toBe("answer");
|
|
534
|
-
expect(result.content).toBeDefined();
|
|
535
|
-
});
|
|
536
|
-
});
|
|
@@ -13,7 +13,7 @@ import { SynthAgent, InMemoryDeploymentStore } from "../src/agent/synth-agent.js
|
|
|
13
13
|
import { registerPartitionRoutes } from "../src/api/partitions.js";
|
|
14
14
|
import { registerEnvironmentRoutes } from "../src/api/environments.js";
|
|
15
15
|
import { registerSettingsRoutes } from "../src/api/settings.js";
|
|
16
|
-
import {
|
|
16
|
+
import { registerOperationRoutes } from "../src/api/operations.js";
|
|
17
17
|
import { registerArtifactRoutes } from "../src/api/artifacts.js";
|
|
18
18
|
import { registerHealthRoutes } from "../src/api/health.js";
|
|
19
19
|
|
|
@@ -76,7 +76,7 @@ async function createTestServer(): Promise<TestContext> {
|
|
|
76
76
|
registerPartitionRoutes(app, partitions, deployments, diary, telemetry);
|
|
77
77
|
registerEnvironmentRoutes(app, environments, deployments, telemetry);
|
|
78
78
|
registerSettingsRoutes(app, settings, telemetry);
|
|
79
|
-
|
|
79
|
+
registerOperationRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
|
|
80
80
|
registerArtifactRoutes(app, artifactStore, telemetry);
|
|
81
81
|
registerHealthRoutes(app);
|
|
82
82
|
|
|
@@ -93,7 +93,7 @@ async function deployViaHttp(
|
|
|
93
93
|
) {
|
|
94
94
|
return server.inject({
|
|
95
95
|
method: "POST",
|
|
96
|
-
url: "/api/
|
|
96
|
+
url: "/api/operations",
|
|
97
97
|
payload: {
|
|
98
98
|
artifactId: params.artifactId,
|
|
99
99
|
environmentId: params.environmentId,
|
|
@@ -637,7 +637,7 @@ describe("Settings Routes", () => {
|
|
|
637
637
|
expect(body.settings.environmentsEnabled).toBe(true);
|
|
638
638
|
expect(body.settings.agent).toBeDefined();
|
|
639
639
|
expect(body.settings.agent.conflictPolicy).toBe("permissive");
|
|
640
|
-
expect(body.settings.
|
|
640
|
+
expect(body.settings.operationDefaults).toBeDefined();
|
|
641
641
|
expect(body.settings.envoy).toBeDefined();
|
|
642
642
|
});
|
|
643
643
|
});
|
|
@@ -763,9 +763,9 @@ describe("Deployment Routes", () => {
|
|
|
763
763
|
return artifact.id;
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
-
// --- POST /api/
|
|
766
|
+
// --- POST /api/operations ---
|
|
767
767
|
|
|
768
|
-
describe("POST /api/
|
|
768
|
+
describe("POST /api/operations", () => {
|
|
769
769
|
it("creates a deployment and returns 201", async () => {
|
|
770
770
|
const env = ctx.environments.create("production", { APP_ENV: "production" });
|
|
771
771
|
const partition = ctx.partitions.create("Acme", { DB_HOST: "acme-db" });
|
|
@@ -781,7 +781,7 @@ describe("Deployment Routes", () => {
|
|
|
781
781
|
expect(res.statusCode).toBe(201);
|
|
782
782
|
const body = JSON.parse(res.payload);
|
|
783
783
|
expect(body.deployment).toBeDefined();
|
|
784
|
-
expect(body.deployment.artifactId).toBe(artifactId);
|
|
784
|
+
expect((body.deployment.input as any).artifactId).toBe(artifactId);
|
|
785
785
|
expect(body.deployment.partitionId).toBe(partition.id);
|
|
786
786
|
expect(body.deployment.version).toBe("1.0.0");
|
|
787
787
|
});
|
|
@@ -789,7 +789,7 @@ describe("Deployment Routes", () => {
|
|
|
789
789
|
it("returns 400 for invalid trigger", async () => {
|
|
790
790
|
const res = await ctx.app.inject({
|
|
791
791
|
method: "POST",
|
|
792
|
-
url: "/api/
|
|
792
|
+
url: "/api/operations",
|
|
793
793
|
payload: {},
|
|
794
794
|
});
|
|
795
795
|
|
|
@@ -801,7 +801,7 @@ describe("Deployment Routes", () => {
|
|
|
801
801
|
|
|
802
802
|
const res = await ctx.app.inject({
|
|
803
803
|
method: "POST",
|
|
804
|
-
url: "/api/
|
|
804
|
+
url: "/api/operations",
|
|
805
805
|
payload: {
|
|
806
806
|
artifactId: "nonexistent-artifact",
|
|
807
807
|
environmentId: env.id,
|
|
@@ -817,7 +817,7 @@ describe("Deployment Routes", () => {
|
|
|
817
817
|
|
|
818
818
|
const res = await ctx.app.inject({
|
|
819
819
|
method: "POST",
|
|
820
|
-
url: "/api/
|
|
820
|
+
url: "/api/operations",
|
|
821
821
|
payload: {
|
|
822
822
|
artifactId,
|
|
823
823
|
environmentId: "nonexistent",
|
|
@@ -834,7 +834,7 @@ describe("Deployment Routes", () => {
|
|
|
834
834
|
|
|
835
835
|
const res = await ctx.app.inject({
|
|
836
836
|
method: "POST",
|
|
837
|
-
url: "/api/
|
|
837
|
+
url: "/api/operations",
|
|
838
838
|
payload: {
|
|
839
839
|
artifactId,
|
|
840
840
|
environmentId: env.id,
|
|
@@ -847,13 +847,13 @@ describe("Deployment Routes", () => {
|
|
|
847
847
|
});
|
|
848
848
|
});
|
|
849
849
|
|
|
850
|
-
// --- GET /api/
|
|
850
|
+
// --- GET /api/operations ---
|
|
851
851
|
|
|
852
|
-
describe("GET /api/
|
|
852
|
+
describe("GET /api/operations", () => {
|
|
853
853
|
it("returns empty list when no deployments exist", async () => {
|
|
854
854
|
const res = await ctx.app.inject({
|
|
855
855
|
method: "GET",
|
|
856
|
-
url: "/api/
|
|
856
|
+
url: "/api/operations",
|
|
857
857
|
});
|
|
858
858
|
|
|
859
859
|
expect(res.statusCode).toBe(200);
|
|
@@ -875,7 +875,7 @@ describe("Deployment Routes", () => {
|
|
|
875
875
|
|
|
876
876
|
const res = await ctx.app.inject({
|
|
877
877
|
method: "GET",
|
|
878
|
-
url: "/api/
|
|
878
|
+
url: "/api/operations",
|
|
879
879
|
});
|
|
880
880
|
|
|
881
881
|
expect(res.statusCode).toBe(200);
|
|
@@ -904,7 +904,7 @@ describe("Deployment Routes", () => {
|
|
|
904
904
|
|
|
905
905
|
const res = await ctx.app.inject({
|
|
906
906
|
method: "GET",
|
|
907
|
-
url: `/api/
|
|
907
|
+
url: `/api/operations?partitionId=${p1.id}`,
|
|
908
908
|
});
|
|
909
909
|
|
|
910
910
|
expect(res.statusCode).toBe(200);
|
|
@@ -934,19 +934,19 @@ describe("Deployment Routes", () => {
|
|
|
934
934
|
|
|
935
935
|
const res = await ctx.app.inject({
|
|
936
936
|
method: "GET",
|
|
937
|
-
url: `/api/
|
|
937
|
+
url: `/api/operations?artifactId=${art1}`,
|
|
938
938
|
});
|
|
939
939
|
|
|
940
940
|
expect(res.statusCode).toBe(200);
|
|
941
941
|
const body = JSON.parse(res.payload);
|
|
942
942
|
expect(body.deployments).toHaveLength(1);
|
|
943
|
-
expect(body.deployments[0].artifactId).toBe(art1);
|
|
943
|
+
expect((body.deployments[0].input as any).artifactId).toBe(art1);
|
|
944
944
|
});
|
|
945
945
|
});
|
|
946
946
|
|
|
947
|
-
// --- GET /api/
|
|
947
|
+
// --- GET /api/operations/:id ---
|
|
948
948
|
|
|
949
|
-
describe("GET /api/
|
|
949
|
+
describe("GET /api/operations/:id", () => {
|
|
950
950
|
it("returns a deployment with debrief entries", async () => {
|
|
951
951
|
const env = ctx.environments.create("production");
|
|
952
952
|
const partition = ctx.partitions.create("Acme");
|
|
@@ -962,7 +962,7 @@ describe("Deployment Routes", () => {
|
|
|
962
962
|
|
|
963
963
|
const res = await ctx.app.inject({
|
|
964
964
|
method: "GET",
|
|
965
|
-
url: `/api/
|
|
965
|
+
url: `/api/operations/${deploymentId}`,
|
|
966
966
|
});
|
|
967
967
|
|
|
968
968
|
expect(res.statusCode).toBe(200);
|
|
@@ -975,18 +975,18 @@ describe("Deployment Routes", () => {
|
|
|
975
975
|
it("returns 404 for non-existent deployment", async () => {
|
|
976
976
|
const res = await ctx.app.inject({
|
|
977
977
|
method: "GET",
|
|
978
|
-
url: "/api/
|
|
978
|
+
url: "/api/operations/does-not-exist",
|
|
979
979
|
});
|
|
980
980
|
|
|
981
981
|
expect(res.statusCode).toBe(404);
|
|
982
982
|
const body = JSON.parse(res.payload);
|
|
983
|
-
expect(body.error).toBe("
|
|
983
|
+
expect(body.error).toBe("Operation not found");
|
|
984
984
|
});
|
|
985
985
|
});
|
|
986
986
|
|
|
987
|
-
// --- GET /api/
|
|
987
|
+
// --- GET /api/operations/:id/postmortem ---
|
|
988
988
|
|
|
989
|
-
describe("GET /api/
|
|
989
|
+
describe("GET /api/operations/:id/postmortem", () => {
|
|
990
990
|
it("returns a postmortem for a deployment", async () => {
|
|
991
991
|
const env = ctx.environments.create("production");
|
|
992
992
|
const partition = ctx.partitions.create("Acme");
|
|
@@ -1002,7 +1002,7 @@ describe("Deployment Routes", () => {
|
|
|
1002
1002
|
|
|
1003
1003
|
const res = await ctx.app.inject({
|
|
1004
1004
|
method: "GET",
|
|
1005
|
-
url: `/api/
|
|
1005
|
+
url: `/api/operations/${deploymentId}/postmortem`,
|
|
1006
1006
|
});
|
|
1007
1007
|
|
|
1008
1008
|
expect(res.statusCode).toBe(200);
|
|
@@ -1013,7 +1013,7 @@ describe("Deployment Routes", () => {
|
|
|
1013
1013
|
it("returns 404 for non-existent deployment", async () => {
|
|
1014
1014
|
const res = await ctx.app.inject({
|
|
1015
1015
|
method: "GET",
|
|
1016
|
-
url: "/api/
|
|
1016
|
+
url: "/api/operations/does-not-exist/postmortem",
|
|
1017
1017
|
});
|
|
1018
1018
|
|
|
1019
1019
|
expect(res.statusCode).toBe(404);
|