@synth-deploy/server 0.1.0 → 1.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.
Files changed (96) hide show
  1. package/dist/agent/envoy-client.d.ts +62 -7
  2. package/dist/agent/envoy-client.d.ts.map +1 -1
  3. package/dist/agent/envoy-client.js +56 -6
  4. package/dist/agent/envoy-client.js.map +1 -1
  5. package/dist/agent/stale-deployment-detector.js +1 -1
  6. package/dist/agent/stale-deployment-detector.js.map +1 -1
  7. package/dist/agent/synth-agent.d.ts +7 -5
  8. package/dist/agent/synth-agent.d.ts.map +1 -1
  9. package/dist/agent/synth-agent.js +42 -39
  10. package/dist/agent/synth-agent.js.map +1 -1
  11. package/dist/alert-webhooks/alert-parsers.d.ts +21 -0
  12. package/dist/alert-webhooks/alert-parsers.d.ts.map +1 -0
  13. package/dist/alert-webhooks/alert-parsers.js +184 -0
  14. package/dist/alert-webhooks/alert-parsers.js.map +1 -0
  15. package/dist/api/agent.d.ts +0 -6
  16. package/dist/api/agent.d.ts.map +1 -1
  17. package/dist/api/agent.js +6 -459
  18. package/dist/api/agent.js.map +1 -1
  19. package/dist/api/alert-webhooks.d.ts +13 -0
  20. package/dist/api/alert-webhooks.d.ts.map +1 -0
  21. package/dist/api/alert-webhooks.js +279 -0
  22. package/dist/api/alert-webhooks.js.map +1 -0
  23. package/dist/api/envoy-reports.js +2 -2
  24. package/dist/api/envoy-reports.js.map +1 -1
  25. package/dist/api/envoys.js +1 -1
  26. package/dist/api/envoys.js.map +1 -1
  27. package/dist/api/fleet.d.ts.map +1 -1
  28. package/dist/api/fleet.js +14 -15
  29. package/dist/api/fleet.js.map +1 -1
  30. package/dist/api/graph.js +3 -3
  31. package/dist/api/graph.js.map +1 -1
  32. package/dist/api/operations.d.ts +7 -0
  33. package/dist/api/operations.d.ts.map +1 -0
  34. package/dist/api/operations.js +1883 -0
  35. package/dist/api/operations.js.map +1 -0
  36. package/dist/api/partitions.js +1 -1
  37. package/dist/api/partitions.js.map +1 -1
  38. package/dist/api/schemas.d.ts +194 -10
  39. package/dist/api/schemas.d.ts.map +1 -1
  40. package/dist/api/schemas.js +38 -5
  41. package/dist/api/schemas.js.map +1 -1
  42. package/dist/api/system.d.ts.map +1 -1
  43. package/dist/api/system.js +22 -21
  44. package/dist/api/system.js.map +1 -1
  45. package/dist/artifact-analyzer.js +2 -2
  46. package/dist/artifact-analyzer.js.map +1 -1
  47. package/dist/fleet/fleet-executor.js +1 -1
  48. package/dist/fleet/fleet-executor.js.map +1 -1
  49. package/dist/graph/graph-executor.js +2 -2
  50. package/dist/graph/graph-executor.js.map +1 -1
  51. package/dist/index.js +44 -40
  52. package/dist/index.js.map +1 -1
  53. package/dist/mcp/resources.js +3 -3
  54. package/dist/mcp/resources.js.map +1 -1
  55. package/dist/mcp/tools.d.ts.map +1 -1
  56. package/dist/mcp/tools.js +2 -9
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/middleware/auth.js +1 -1
  59. package/dist/middleware/auth.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/agent/envoy-client.ts +107 -15
  62. package/src/agent/stale-deployment-detector.ts +1 -1
  63. package/src/agent/synth-agent.ts +59 -45
  64. package/src/alert-webhooks/alert-parsers.ts +291 -0
  65. package/src/api/agent.ts +9 -528
  66. package/src/api/alert-webhooks.ts +354 -0
  67. package/src/api/envoy-reports.ts +2 -2
  68. package/src/api/envoys.ts +1 -1
  69. package/src/api/fleet.ts +14 -15
  70. package/src/api/graph.ts +3 -3
  71. package/src/api/operations.ts +2240 -0
  72. package/src/api/partitions.ts +1 -1
  73. package/src/api/schemas.ts +43 -7
  74. package/src/api/system.ts +23 -21
  75. package/src/artifact-analyzer.ts +2 -2
  76. package/src/fleet/fleet-executor.ts +1 -1
  77. package/src/graph/graph-executor.ts +2 -2
  78. package/src/index.ts +46 -40
  79. package/src/mcp/resources.ts +3 -3
  80. package/src/mcp/tools.ts +5 -9
  81. package/src/middleware/auth.ts +1 -1
  82. package/tests/agent-mode.test.ts +5 -376
  83. package/tests/api-handlers.test.ts +27 -27
  84. package/tests/composite-operations.test.ts +557 -0
  85. package/tests/decision-diary.test.ts +62 -63
  86. package/tests/diary-reader.test.ts +14 -18
  87. package/tests/mcp-tools.test.ts +1 -1
  88. package/tests/orchestration.test.ts +34 -30
  89. package/tests/partition-isolation.test.ts +4 -9
  90. package/tests/rbac-enforcement.test.ts +8 -8
  91. package/tests/ui-journey.test.ts +9 -9
  92. package/dist/api/deployments.d.ts +0 -11
  93. package/dist/api/deployments.d.ts.map +0 -1
  94. package/dist/api/deployments.js +0 -1098
  95. package/dist/api/deployments.js.map +0 -1
  96. package/src/api/deployments.ts +0 -1347
@@ -50,9 +50,9 @@ export function registerResources(
50
50
  async (uri, { deploymentId }) => {
51
51
  const deployment = deployments.get(deploymentId as string);
52
52
  if (!deployment) {
53
- return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "Deployment not found" }) }] };
53
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "Operation not found" }) }] };
54
54
  }
55
- const entries = debrief.getByDeployment(deploymentId as string);
55
+ const entries = debrief.getByOperation(deploymentId as string);
56
56
  return {
57
57
  contents: [
58
58
  {
@@ -71,7 +71,7 @@ export function registerResources(
71
71
  list: async () => ({
72
72
  resources: deployments.list().map((d) => ({
73
73
  uri: `deployment://${d.id}`,
74
- name: `${d.artifactId} v${d.version} → ${d.environmentId}`,
74
+ name: `${d.input.type === 'deploy' ? d.input.artifactId : d.intent ?? d.input.type} v${d.version ?? ''} → ${d.environmentId}`,
75
75
  })),
76
76
  }),
77
77
  }),
package/src/mcp/tools.ts CHANGED
@@ -57,14 +57,10 @@ export function registerTools(
57
57
  }
58
58
  }
59
59
 
60
- const deployment = await agent.triggerDeployment({
61
- artifactId,
62
- artifactVersionId: version,
63
- environmentId,
64
- partitionId,
65
- triggeredBy: "agent",
66
- variables,
67
- });
60
+ const deployment = await agent.triggerOperation(
61
+ { type: "deploy", artifactId, ...(version ? { artifactVersionId: version } : {}) },
62
+ { environmentId, partitionId, triggeredBy: "agent", variables },
63
+ );
68
64
 
69
65
  return {
70
66
  content: [
@@ -99,7 +95,7 @@ export function registerTools(
99
95
  const deployment = deployments.get(deploymentId);
100
96
  if (!deployment) {
101
97
  return {
102
- content: [{ type: "text", text: `Error: Deployment not found: ${deploymentId}` }],
98
+ content: [{ type: "text", text: `Error: Operation not found: ${deploymentId}` }],
103
99
  isError: true,
104
100
  };
105
101
  }
@@ -16,7 +16,7 @@ declare module "fastify" {
16
16
  }
17
17
 
18
18
  const EXEMPT_ROUTES = ["/health", "/api/health", "/api/auth/login", "/api/auth/register", "/api/auth/refresh", "/api/auth/status", "/api/auth/providers", "/api/envoy/report"];
19
- const EXEMPT_PREFIXES = ["/api/auth/oidc/", "/api/auth/callback/oidc/", "/api/auth/saml/", "/api/auth/callback/saml/", "/api/auth/ldap/", "/api/intake/webhook/"];
19
+ const EXEMPT_PREFIXES = ["/api/auth/oidc/", "/api/auth/callback/oidc/", "/api/auth/saml/", "/api/auth/callback/saml/", "/api/auth/ldap/", "/api/intake/webhook/", "/api/alert-webhooks/receive/"];
20
20
  // Envoy callback endpoints — validated by envoy token, not user JWT
21
21
  const EXEMPT_PATTERNS = [/^\/api\/deployments\/[^/]+\/progress$/];
22
22
 
@@ -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, LlmClient } from "@synth-deploy/core";
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 { registerDeploymentRoutes } from "../src/api/deployments.js";
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, sanitizeUserInput, validateExtractedVersion, validateExtractedVariables } from "../src/api/agent.js";
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
- registerDeploymentRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
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/deployments",
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("&lt;script&gt;");
195
- expect(result).toContain("&lt;/script&gt;");
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 { registerDeploymentRoutes } from "../src/api/deployments.js";
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
- registerDeploymentRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
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/deployments",
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.deploymentDefaults).toBeDefined();
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/deployments ---
766
+ // --- POST /api/operations ---
767
767
 
768
- describe("POST /api/deployments", () => {
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/deployments",
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/deployments",
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/deployments",
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/deployments",
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/deployments ---
850
+ // --- GET /api/operations ---
851
851
 
852
- describe("GET /api/deployments", () => {
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/deployments",
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/deployments",
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/deployments?partitionId=${p1.id}`,
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/deployments?artifactId=${art1}`,
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/deployments/:id ---
947
+ // --- GET /api/operations/:id ---
948
948
 
949
- describe("GET /api/deployments/:id", () => {
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/deployments/${deploymentId}`,
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/deployments/does-not-exist",
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("Deployment not found");
983
+ expect(body.error).toBe("Operation not found");
984
984
  });
985
985
  });
986
986
 
987
- // --- GET /api/deployments/:id/postmortem ---
987
+ // --- GET /api/operations/:id/postmortem ---
988
988
 
989
- describe("GET /api/deployments/:id/postmortem", () => {
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/deployments/${deploymentId}/postmortem`,
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/deployments/does-not-exist/postmortem",
1016
+ url: "/api/operations/does-not-exist/postmortem",
1017
1017
  });
1018
1018
 
1019
1019
  expect(res.statusCode).toBe(404);