@synth-deploy/server 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/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DecisionDebrief,
|
|
4
|
+
ArtifactStore,
|
|
5
|
+
EnvironmentStore,
|
|
6
|
+
PartitionStore,
|
|
7
|
+
generatePostmortem,
|
|
8
|
+
generateOperationHistory,
|
|
9
|
+
} from "@synth-deploy/core";
|
|
10
|
+
import type {
|
|
11
|
+
Partition,
|
|
12
|
+
Environment,
|
|
13
|
+
Deployment,
|
|
14
|
+
} from "@synth-deploy/core";
|
|
15
|
+
import {
|
|
16
|
+
SynthAgent,
|
|
17
|
+
InMemoryDeploymentStore,
|
|
18
|
+
} from "../src/agent/synth-agent.js";
|
|
19
|
+
import type {
|
|
20
|
+
ServiceHealthChecker,
|
|
21
|
+
HealthCheckResult,
|
|
22
|
+
} from "../src/agent/health-checker.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Test helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
class MockHealthChecker implements ServiceHealthChecker {
|
|
29
|
+
private responses: HealthCheckResult[] = [];
|
|
30
|
+
|
|
31
|
+
willReturn(...results: HealthCheckResult[]): void {
|
|
32
|
+
this.responses.push(...results);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async check(): Promise<HealthCheckResult> {
|
|
36
|
+
const next = this.responses.shift();
|
|
37
|
+
if (next) return next;
|
|
38
|
+
return { reachable: true, responseTimeMs: 1, error: null };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HEALTHY: HealthCheckResult = {
|
|
43
|
+
reachable: true,
|
|
44
|
+
responseTimeMs: 5,
|
|
45
|
+
error: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const CONN_REFUSED: HealthCheckResult = {
|
|
49
|
+
reachable: false,
|
|
50
|
+
responseTimeMs: null,
|
|
51
|
+
error: "ECONNREFUSED: Connection refused",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DNS_FAILURE: HealthCheckResult = {
|
|
55
|
+
reachable: false,
|
|
56
|
+
responseTimeMs: null,
|
|
57
|
+
error: "ENOTFOUND: DNS resolution failed for production.example.com",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function makePartition(overrides: Partial<Partition> = {}): Partition {
|
|
61
|
+
return {
|
|
62
|
+
id: "partition-1",
|
|
63
|
+
name: "Acme Corp",
|
|
64
|
+
variables: {},
|
|
65
|
+
createdAt: new Date(),
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeEnvironment(overrides: Partial<Environment> = {}): Environment {
|
|
71
|
+
return {
|
|
72
|
+
id: "env-prod",
|
|
73
|
+
name: "production",
|
|
74
|
+
variables: {},
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DEFAULT_ARTIFACT_ID = "artifact-web-app";
|
|
80
|
+
|
|
81
|
+
function forceInsertPartition(store: PartitionStore, p: Partition): void {
|
|
82
|
+
if (!store.get(p.id)) {
|
|
83
|
+
(store as any).partitions.set(p.id, p);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function forceInsertEnvironment(store: EnvironmentStore, e: Environment): void {
|
|
88
|
+
if (!store.get(e.id)) {
|
|
89
|
+
(store as any).environments.set(e.id, e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function forceInsertArtifact(store: ArtifactStore, id: string, name: string): void {
|
|
94
|
+
if (!store.get(id)) {
|
|
95
|
+
(store as any).artifacts.set(id, {
|
|
96
|
+
id,
|
|
97
|
+
name,
|
|
98
|
+
type: "nodejs",
|
|
99
|
+
analysis: {
|
|
100
|
+
summary: "test artifact",
|
|
101
|
+
dependencies: [],
|
|
102
|
+
configurationExpectations: {},
|
|
103
|
+
deploymentIntent: "rolling",
|
|
104
|
+
confidence: 0.9,
|
|
105
|
+
},
|
|
106
|
+
annotations: [],
|
|
107
|
+
learningHistory: [],
|
|
108
|
+
createdAt: new Date(),
|
|
109
|
+
updatedAt: new Date(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface TestStores {
|
|
115
|
+
partitions: PartitionStore;
|
|
116
|
+
environments: EnvironmentStore;
|
|
117
|
+
artifacts: ArtifactStore;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Test convenience: seeds stores with entities and triggers a deployment.
|
|
122
|
+
* Mirrors the old testDeploy helper that accepted bare partition/environment/operation objects.
|
|
123
|
+
*/
|
|
124
|
+
async function testDeploy(
|
|
125
|
+
agent: SynthAgent,
|
|
126
|
+
stores: TestStores,
|
|
127
|
+
oldTrigger: { partitionId?: string; version?: string; variables?: Record<string, string> },
|
|
128
|
+
partition?: Partition,
|
|
129
|
+
environment?: Environment,
|
|
130
|
+
): Promise<Deployment> {
|
|
131
|
+
const p = partition ?? makePartition();
|
|
132
|
+
const e = environment ?? makeEnvironment();
|
|
133
|
+
const version = oldTrigger.version ?? "2.0.0";
|
|
134
|
+
|
|
135
|
+
const effectivePartition = oldTrigger.partitionId && oldTrigger.partitionId !== p.id
|
|
136
|
+
? makePartition({ id: oldTrigger.partitionId })
|
|
137
|
+
: p;
|
|
138
|
+
|
|
139
|
+
// Ensure entities are in stores so the agent can look them up
|
|
140
|
+
forceInsertPartition(stores.partitions, effectivePartition);
|
|
141
|
+
forceInsertEnvironment(stores.environments, e);
|
|
142
|
+
forceInsertArtifact(stores.artifacts, DEFAULT_ARTIFACT_ID, "web-app");
|
|
143
|
+
|
|
144
|
+
return agent.triggerDeployment({
|
|
145
|
+
artifactId: DEFAULT_ARTIFACT_ID,
|
|
146
|
+
artifactVersionId: version,
|
|
147
|
+
environmentId: e.id,
|
|
148
|
+
partitionId: effectivePartition.id,
|
|
149
|
+
triggeredBy: "user",
|
|
150
|
+
variables: oldTrigger.variables,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// SCENARIO 1: Simulated Postmortem
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
//
|
|
158
|
+
// Success condition: Given a deployment that failed, a reviewer should be
|
|
159
|
+
// able to read the Diary and understand exactly what the agent decided,
|
|
160
|
+
// why it rolled back or continued, and what the suggested fix is,
|
|
161
|
+
// without reading any log files.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe("Simulated Postmortem — failed deployment read experience", () => {
|
|
165
|
+
let diary: DecisionDebrief;
|
|
166
|
+
let deployments: InMemoryDeploymentStore;
|
|
167
|
+
let healthChecker: MockHealthChecker;
|
|
168
|
+
let agent: SynthAgent;
|
|
169
|
+
let stores: TestStores;
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
diary = new DecisionDebrief();
|
|
173
|
+
deployments = new InMemoryDeploymentStore();
|
|
174
|
+
healthChecker = new MockHealthChecker();
|
|
175
|
+
const partStore = new PartitionStore();
|
|
176
|
+
const envStore = new EnvironmentStore();
|
|
177
|
+
const artStore = new ArtifactStore();
|
|
178
|
+
stores = { partitions: partStore, environments: envStore, artifacts: artStore };
|
|
179
|
+
agent = new SynthAgent(diary, deployments, artStore, envStore, partStore, healthChecker, {
|
|
180
|
+
healthCheckBackoffMs: 1,
|
|
181
|
+
executionDelayMs: 1,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("health check failure postmortem — reviewer understands what happened without logs", async () => {
|
|
186
|
+
// Scenario: deployment fails because target environment is unreachable
|
|
187
|
+
healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
|
|
188
|
+
|
|
189
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
190
|
+
|
|
191
|
+
expect(deployment.status).toBe("failed");
|
|
192
|
+
|
|
193
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
194
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
195
|
+
|
|
196
|
+
// 1. Reviewer can identify WHAT was being deployed
|
|
197
|
+
expect(postmortem.summary).toContain("web-app");
|
|
198
|
+
expect(postmortem.summary).toContain("v2.0.0");
|
|
199
|
+
expect(postmortem.summary).toContain("production");
|
|
200
|
+
expect(postmortem.summary).toContain("Acme Corp");
|
|
201
|
+
expect(postmortem.summary).toContain("FAILED");
|
|
202
|
+
|
|
203
|
+
// 2. Reviewer can see every decision in chronological order
|
|
204
|
+
expect(postmortem.timeline.length).toBeGreaterThanOrEqual(3);
|
|
205
|
+
const steps = postmortem.timeline.map((t) => t.step);
|
|
206
|
+
expect(steps).toContain("pipeline-plan");
|
|
207
|
+
expect(steps).toContain("configuration-resolved");
|
|
208
|
+
expect(steps).toContain("health-check");
|
|
209
|
+
|
|
210
|
+
// 3. Reviewer can understand WHY it failed
|
|
211
|
+
expect(postmortem.failureAnalysis).not.toBeNull();
|
|
212
|
+
expect(postmortem.failureAnalysis!.failedStep).toBe(
|
|
213
|
+
"preflight-health-check",
|
|
214
|
+
);
|
|
215
|
+
expect(postmortem.failureAnalysis!.whatHappened).toContain(
|
|
216
|
+
"Deployment failed",
|
|
217
|
+
);
|
|
218
|
+
expect(postmortem.failureAnalysis!.whyItFailed).toContain(
|
|
219
|
+
"ECONNREFUSED",
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// 4. Reviewer gets a SUGGESTED FIX
|
|
223
|
+
expect(postmortem.failureAnalysis!.suggestedFix.length).toBeGreaterThan(
|
|
224
|
+
10,
|
|
225
|
+
);
|
|
226
|
+
expect(
|
|
227
|
+
postmortem.failureAnalysis!.suggestedFix.toLowerCase(),
|
|
228
|
+
).toContain("verify");
|
|
229
|
+
|
|
230
|
+
// 5. The formatted output is self-contained
|
|
231
|
+
expect(postmortem.formatted).toContain("# Deployment Postmortem");
|
|
232
|
+
expect(postmortem.formatted).toContain("## Summary");
|
|
233
|
+
expect(postmortem.formatted).toContain("## Decision Timeline");
|
|
234
|
+
expect(postmortem.formatted).toContain("## Failure Analysis");
|
|
235
|
+
expect(postmortem.formatted).toContain("### Suggested Fix");
|
|
236
|
+
expect(postmortem.formatted).toContain("## Outcome");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("DNS failure postmortem — immediate abort explained clearly", async () => {
|
|
240
|
+
healthChecker.willReturn(DNS_FAILURE);
|
|
241
|
+
|
|
242
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
243
|
+
|
|
244
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
245
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
246
|
+
|
|
247
|
+
// The postmortem should explain that DNS failures don't benefit from retry
|
|
248
|
+
expect(postmortem.failureAnalysis).not.toBeNull();
|
|
249
|
+
expect(postmortem.failureAnalysis!.whyItFailed).toContain("DNS");
|
|
250
|
+
expect(postmortem.failureAnalysis!.suggestedFix.toLowerCase()).toContain(
|
|
251
|
+
"dns",
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Timeline should show the abort happened without retry
|
|
255
|
+
const healthEntries = postmortem.timeline.filter(
|
|
256
|
+
(t) => t.step === "health-check",
|
|
257
|
+
);
|
|
258
|
+
expect(healthEntries.length).toBeGreaterThanOrEqual(1);
|
|
259
|
+
const abortEntry = healthEntries.find((h) =>
|
|
260
|
+
h.decision.toLowerCase().includes("abort"),
|
|
261
|
+
);
|
|
262
|
+
expect(abortEntry).toBeDefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("configuration block postmortem — cross-env conflict explained", async () => {
|
|
266
|
+
// Two connectivity variables pointing cross-environment → should block
|
|
267
|
+
const partition = makePartition({
|
|
268
|
+
variables: {
|
|
269
|
+
DB_HOST: "staging-db.internal",
|
|
270
|
+
API_ENDPOINT: "https://staging-api.example.com",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
const env = makeEnvironment({
|
|
274
|
+
name: "production",
|
|
275
|
+
variables: {
|
|
276
|
+
DB_HOST: "prod-db.internal",
|
|
277
|
+
API_ENDPOINT: "https://prod-api.example.com",
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
healthChecker.willReturn(HEALTHY);
|
|
282
|
+
|
|
283
|
+
const deployment = await testDeploy(agent, stores, {}, partition, env);
|
|
284
|
+
|
|
285
|
+
expect(deployment.status).toBe("failed");
|
|
286
|
+
|
|
287
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
288
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
289
|
+
|
|
290
|
+
// Should explain the configuration block
|
|
291
|
+
expect(postmortem.failureAnalysis).not.toBeNull();
|
|
292
|
+
expect(postmortem.failureAnalysis!.failedStep).toBe(
|
|
293
|
+
"resolve-configuration",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Conflicts should be surfaced
|
|
297
|
+
expect(postmortem.configuration.conflictCount).toBeGreaterThan(0);
|
|
298
|
+
expect(postmortem.configuration.conflicts.length).toBeGreaterThan(0);
|
|
299
|
+
|
|
300
|
+
// Suggested fix should mention verifying variable bindings
|
|
301
|
+
expect(postmortem.failureAnalysis!.suggestedFix.length).toBeGreaterThan(
|
|
302
|
+
10,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Formatted output should contain conflict details
|
|
306
|
+
expect(postmortem.formatted).toContain("Conflicts");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("successful deployment postmortem — no failure analysis, clean outcome", async () => {
|
|
310
|
+
healthChecker.willReturn(HEALTHY);
|
|
311
|
+
|
|
312
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
313
|
+
|
|
314
|
+
expect(deployment.status).toBe("succeeded");
|
|
315
|
+
|
|
316
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
317
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
318
|
+
|
|
319
|
+
expect(postmortem.summary).toContain("SUCCEEDED");
|
|
320
|
+
expect(postmortem.failureAnalysis).toBeNull();
|
|
321
|
+
expect(postmortem.outcome).toContain("completed");
|
|
322
|
+
|
|
323
|
+
// Should NOT contain failure analysis section in formatted output
|
|
324
|
+
expect(postmortem.formatted).not.toContain("## Failure Analysis");
|
|
325
|
+
expect(postmortem.formatted).toContain("## Outcome");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("retry-then-succeed postmortem — shows the full decision chain", async () => {
|
|
329
|
+
// First health check fails (connection refused), retry succeeds
|
|
330
|
+
healthChecker.willReturn(CONN_REFUSED, HEALTHY);
|
|
331
|
+
|
|
332
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
333
|
+
|
|
334
|
+
expect(deployment.status).toBe("succeeded");
|
|
335
|
+
|
|
336
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
337
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
338
|
+
|
|
339
|
+
// Timeline should show the retry decision chain
|
|
340
|
+
const healthEntries = postmortem.timeline.filter(
|
|
341
|
+
(t) => t.step === "health-check",
|
|
342
|
+
);
|
|
343
|
+
expect(healthEntries.length).toBeGreaterThanOrEqual(2);
|
|
344
|
+
|
|
345
|
+
// Should show retry decision and recovery
|
|
346
|
+
const retryEntry = healthEntries.find((h) =>
|
|
347
|
+
h.decision.toLowerCase().includes("retry"),
|
|
348
|
+
);
|
|
349
|
+
const recoveryEntry = healthEntries.find((h) =>
|
|
350
|
+
h.decision.toLowerCase().includes("recovered"),
|
|
351
|
+
);
|
|
352
|
+
expect(retryEntry).toBeDefined();
|
|
353
|
+
expect(recoveryEntry).toBeDefined();
|
|
354
|
+
|
|
355
|
+
// No failure analysis — deployment ultimately succeeded
|
|
356
|
+
expect(postmortem.failureAnalysis).toBeNull();
|
|
357
|
+
expect(postmortem.summary).toContain("SUCCEEDED");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("postmortem formatted output is self-contained — readable without any other document", async () => {
|
|
361
|
+
healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
|
|
362
|
+
|
|
363
|
+
const deployment = await testDeploy(
|
|
364
|
+
agent,
|
|
365
|
+
stores,
|
|
366
|
+
{ version: "3.1.0" },
|
|
367
|
+
makePartition({ name: "Widget Inc" }),
|
|
368
|
+
makeEnvironment({ name: "staging" }),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
372
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
373
|
+
|
|
374
|
+
const text = postmortem.formatted;
|
|
375
|
+
|
|
376
|
+
// Contains all identifying information
|
|
377
|
+
expect(text).toContain("web-app");
|
|
378
|
+
expect(text).toContain("3.1.0");
|
|
379
|
+
expect(text).toContain("staging");
|
|
380
|
+
expect(text).toContain("Widget Inc");
|
|
381
|
+
expect(text).toContain(deployment.id);
|
|
382
|
+
|
|
383
|
+
// Contains timing information
|
|
384
|
+
expect(text).toContain("Started:");
|
|
385
|
+
expect(text).toContain("Completed:");
|
|
386
|
+
expect(text).toContain("Duration:");
|
|
387
|
+
|
|
388
|
+
// Contains the decision timeline with reasoning
|
|
389
|
+
expect(text).toContain("PIPELINE-PLAN");
|
|
390
|
+
expect(text).toContain("Decision:");
|
|
391
|
+
expect(text).toContain("Reasoning:");
|
|
392
|
+
|
|
393
|
+
// Contains suggested fix
|
|
394
|
+
expect(text).toContain("Suggested Fix");
|
|
395
|
+
|
|
396
|
+
// A reviewer reading this text can answer:
|
|
397
|
+
// - What was deployed? (artifact, version, environment, partition)
|
|
398
|
+
// - What did the agent decide? (timeline)
|
|
399
|
+
// - Why did it fail? (failure analysis)
|
|
400
|
+
// - What should I do? (suggested fix)
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// SCENARIO 2: Simulated Onboarding Read
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
//
|
|
408
|
+
// Success condition: Given an artifact with 10 deployments in its history,
|
|
409
|
+
// a new engineer should be able to read the Diary and understand the
|
|
410
|
+
// artifact's configuration decisions and deployment patterns.
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
describe("Simulated Onboarding — operation history read experience", () => {
|
|
414
|
+
let diary: DecisionDebrief;
|
|
415
|
+
let deploymentStore: InMemoryDeploymentStore;
|
|
416
|
+
let healthChecker: MockHealthChecker;
|
|
417
|
+
let agent: SynthAgent;
|
|
418
|
+
let stores: TestStores;
|
|
419
|
+
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
diary = new DecisionDebrief();
|
|
422
|
+
deploymentStore = new InMemoryDeploymentStore();
|
|
423
|
+
healthChecker = new MockHealthChecker();
|
|
424
|
+
const partStore = new PartitionStore();
|
|
425
|
+
const envStore = new EnvironmentStore();
|
|
426
|
+
const artStore = new ArtifactStore();
|
|
427
|
+
stores = { partitions: partStore, environments: envStore, artifacts: artStore };
|
|
428
|
+
agent = new SynthAgent(diary, deploymentStore, artStore, envStore, partStore, healthChecker, {
|
|
429
|
+
healthCheckBackoffMs: 1,
|
|
430
|
+
executionDelayMs: 1,
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
async function runDeploymentHistory(): Promise<Deployment[]> {
|
|
435
|
+
const partition = makePartition({
|
|
436
|
+
id: "acme",
|
|
437
|
+
name: "Acme Corp",
|
|
438
|
+
variables: { APP_ENV: "production", LOG_LEVEL: "warn" },
|
|
439
|
+
});
|
|
440
|
+
const prodEnv = makeEnvironment({
|
|
441
|
+
id: "env-prod",
|
|
442
|
+
name: "production",
|
|
443
|
+
variables: { APP_ENV: "production", LOG_LEVEL: "info" },
|
|
444
|
+
});
|
|
445
|
+
const stagingEnv = makeEnvironment({
|
|
446
|
+
id: "env-staging",
|
|
447
|
+
name: "staging",
|
|
448
|
+
variables: { APP_ENV: "staging", LOG_LEVEL: "debug" },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const results: Deployment[] = [];
|
|
452
|
+
|
|
453
|
+
// Deployment 1: v1.0.0 to staging — clean success
|
|
454
|
+
healthChecker.willReturn(HEALTHY);
|
|
455
|
+
results.push(
|
|
456
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.0.0" }, partition, stagingEnv),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// Deployment 2: v1.0.0 to production — clean success
|
|
460
|
+
healthChecker.willReturn(HEALTHY);
|
|
461
|
+
results.push(
|
|
462
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.0.0" }, partition, prodEnv),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
// Deployment 3: v1.1.0 to staging — with LOG_LEVEL conflict
|
|
466
|
+
healthChecker.willReturn(HEALTHY);
|
|
467
|
+
results.push(
|
|
468
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.1.0", variables: { LOG_LEVEL: "error" } }, partition, stagingEnv),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// Deployment 4: v1.1.0 to production — health check fails then recovers
|
|
472
|
+
healthChecker.willReturn(CONN_REFUSED, HEALTHY);
|
|
473
|
+
results.push(
|
|
474
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.1.0" }, partition, prodEnv),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// Deployment 5: v1.2.0 to staging — clean
|
|
478
|
+
healthChecker.willReturn(HEALTHY);
|
|
479
|
+
results.push(
|
|
480
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.2.0" }, partition, stagingEnv),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// Deployment 6: v1.2.0 to production — DNS failure
|
|
484
|
+
healthChecker.willReturn(DNS_FAILURE);
|
|
485
|
+
results.push(
|
|
486
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.2.0" }, partition, prodEnv),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// Deployment 7: v1.2.0 to production retry — succeeds after fix
|
|
490
|
+
healthChecker.willReturn(HEALTHY);
|
|
491
|
+
results.push(
|
|
492
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "1.2.0" }, partition, prodEnv),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Deployment 8: v2.0.0 to staging — clean
|
|
496
|
+
healthChecker.willReturn(HEALTHY);
|
|
497
|
+
results.push(
|
|
498
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "2.0.0" }, partition, stagingEnv),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Deployment 9: v2.0.0 to production — with variable conflict
|
|
502
|
+
healthChecker.willReturn(HEALTHY);
|
|
503
|
+
results.push(
|
|
504
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "2.0.0", variables: { LOG_LEVEL: "debug" } }, partition, prodEnv),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// Deployment 10: v2.1.0 to staging — clean
|
|
508
|
+
healthChecker.willReturn(HEALTHY);
|
|
509
|
+
results.push(
|
|
510
|
+
await testDeploy(agent, stores, { partitionId: "acme", version: "2.1.0" }, partition, stagingEnv),
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
return results;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
it("new engineer can see overall operation health at a glance", async () => {
|
|
517
|
+
const deploymentResults = await runDeploymentHistory();
|
|
518
|
+
const allEntries = diary.getByPartition("acme");
|
|
519
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
520
|
+
|
|
521
|
+
// Overview tells the engineer the big picture
|
|
522
|
+
expect(history.overview.totalDeployments).toBe(10);
|
|
523
|
+
expect(history.overview.succeeded).toBe(9);
|
|
524
|
+
expect(history.overview.failed).toBe(1);
|
|
525
|
+
expect(history.overview.successRate).toBe("90%");
|
|
526
|
+
|
|
527
|
+
// Environments used
|
|
528
|
+
expect(history.overview.environments).toContain("production");
|
|
529
|
+
expect(history.overview.environments).toContain("staging");
|
|
530
|
+
|
|
531
|
+
// Versions deployed
|
|
532
|
+
expect(history.overview.versions).toContain("1.0.0");
|
|
533
|
+
expect(history.overview.versions).toContain("1.1.0");
|
|
534
|
+
expect(history.overview.versions).toContain("1.2.0");
|
|
535
|
+
expect(history.overview.versions).toContain("2.0.0");
|
|
536
|
+
expect(history.overview.versions).toContain("2.1.0");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("new engineer can trace every deployment outcome", async () => {
|
|
540
|
+
const deploymentResults = await runDeploymentHistory();
|
|
541
|
+
const allEntries = diary.getByPartition("acme");
|
|
542
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
543
|
+
|
|
544
|
+
// All 10 deployments are listed
|
|
545
|
+
expect(history.deployments).toHaveLength(10);
|
|
546
|
+
|
|
547
|
+
// Each deployment has version, environment, outcome, and key decision
|
|
548
|
+
for (const d of history.deployments) {
|
|
549
|
+
expect(d.version).toBeTruthy();
|
|
550
|
+
expect(d.environment).toBeTruthy();
|
|
551
|
+
expect(["succeeded", "failed"]).toContain(d.outcome);
|
|
552
|
+
expect(d.keyDecision.length).toBeGreaterThan(5);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Deployment 6 (v1.2.0 to production) was the failure
|
|
556
|
+
const failedDeploys = history.deployments.filter(
|
|
557
|
+
(d) => d.outcome === "failed",
|
|
558
|
+
);
|
|
559
|
+
expect(failedDeploys).toHaveLength(1);
|
|
560
|
+
expect(failedDeploys[0].version).toBe("1.2.0");
|
|
561
|
+
expect(failedDeploys[0].environment).toBe("production");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("new engineer can see configuration patterns and recurring issues", async () => {
|
|
565
|
+
const deploymentResults = await runDeploymentHistory();
|
|
566
|
+
const allEntries = diary.getByPartition("acme");
|
|
567
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
568
|
+
|
|
569
|
+
// Configuration patterns are surfaced
|
|
570
|
+
expect(history.configurationPatterns.length).toBeGreaterThan(0);
|
|
571
|
+
|
|
572
|
+
// Should see variable conflict pattern
|
|
573
|
+
const conflictPattern = history.configurationPatterns.find((p) =>
|
|
574
|
+
p.pattern.toLowerCase().includes("conflict") ||
|
|
575
|
+
p.pattern.toLowerCase().includes("override"),
|
|
576
|
+
);
|
|
577
|
+
expect(conflictPattern).toBeDefined();
|
|
578
|
+
expect(conflictPattern!.occurrences).toBeGreaterThanOrEqual(1);
|
|
579
|
+
expect(conflictPattern!.detail.length).toBeGreaterThan(10);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("new engineer can understand per-environment behavior", async () => {
|
|
583
|
+
const deploymentResults = await runDeploymentHistory();
|
|
584
|
+
const allEntries = diary.getByPartition("acme");
|
|
585
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
586
|
+
|
|
587
|
+
// Environment notes present for both environments
|
|
588
|
+
expect(history.environmentNotes.length).toBe(2);
|
|
589
|
+
|
|
590
|
+
const prodNotes = history.environmentNotes.find(
|
|
591
|
+
(n) => n.environment === "production",
|
|
592
|
+
);
|
|
593
|
+
const stagingNotes = history.environmentNotes.find(
|
|
594
|
+
(n) => n.environment === "staging",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
expect(prodNotes).toBeDefined();
|
|
598
|
+
expect(stagingNotes).toBeDefined();
|
|
599
|
+
|
|
600
|
+
// Production had failures
|
|
601
|
+
expect(prodNotes!.deploymentCount).toBe(5);
|
|
602
|
+
// 4 succeeded, 1 failed = 80%
|
|
603
|
+
expect(prodNotes!.successRate).toBe("80%");
|
|
604
|
+
// Production should have notes about failures
|
|
605
|
+
const hasFailureNote = prodNotes!.notes.some((n) =>
|
|
606
|
+
n.toLowerCase().includes("failure"),
|
|
607
|
+
);
|
|
608
|
+
expect(hasFailureNote).toBe(true);
|
|
609
|
+
|
|
610
|
+
// Staging was clean
|
|
611
|
+
expect(stagingNotes!.deploymentCount).toBe(5);
|
|
612
|
+
expect(stagingNotes!.successRate).toBe("100%");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("new engineer can read the formatted output and understand everything", async () => {
|
|
616
|
+
const deploymentResults = await runDeploymentHistory();
|
|
617
|
+
const allEntries = diary.getByPartition("acme");
|
|
618
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
619
|
+
|
|
620
|
+
const text = history.formatted;
|
|
621
|
+
|
|
622
|
+
// Structure is present
|
|
623
|
+
expect(text).toContain("# Operation Deployment History");
|
|
624
|
+
expect(text).toContain("## Overview");
|
|
625
|
+
expect(text).toContain("## Deployment Timeline");
|
|
626
|
+
expect(text).toContain("## Configuration Patterns");
|
|
627
|
+
expect(text).toContain("## Environment Notes");
|
|
628
|
+
|
|
629
|
+
// Key stats are visible
|
|
630
|
+
expect(text).toContain("Total deployments: 10");
|
|
631
|
+
expect(text).toContain("9 succeeded");
|
|
632
|
+
expect(text).toContain("1 failed");
|
|
633
|
+
expect(text).toContain("90%");
|
|
634
|
+
|
|
635
|
+
// Deployment timeline entries are numbered
|
|
636
|
+
expect(text).toContain("1. v1.0.0");
|
|
637
|
+
expect(text).toContain("10. v2.1.0");
|
|
638
|
+
|
|
639
|
+
// Environment breakdown is present
|
|
640
|
+
expect(text).toContain("### production");
|
|
641
|
+
expect(text).toContain("### staging");
|
|
642
|
+
|
|
643
|
+
// A new engineer reading this text can answer:
|
|
644
|
+
// - How many deployments has this artifact had? (10)
|
|
645
|
+
// - What's the success rate? (90%)
|
|
646
|
+
// - Which environments are used? (production, staging)
|
|
647
|
+
// - What versions have been deployed? (1.0.0 through 2.1.0)
|
|
648
|
+
// - Are there recurring configuration issues? (yes, variable conflicts)
|
|
649
|
+
// - Which environment has problems? (production — 80% vs staging 100%)
|
|
650
|
+
// - What kind of failures occur? (DNS, health check issues)
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("deployment timeline shows outcome markers (OK vs FAILED) for quick scanning", async () => {
|
|
654
|
+
const deploymentResults = await runDeploymentHistory();
|
|
655
|
+
const allEntries = diary.getByPartition("acme");
|
|
656
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
657
|
+
|
|
658
|
+
const text = history.formatted;
|
|
659
|
+
|
|
660
|
+
// Quick-scan markers
|
|
661
|
+
const okCount = (text.match(/-- OK/g) || []).length;
|
|
662
|
+
const failedCount = (text.match(/-- FAILED/g) || []).length;
|
|
663
|
+
|
|
664
|
+
expect(okCount).toBe(9);
|
|
665
|
+
expect(failedCount).toBe(1);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("deployment timeline shows conflict counts where they occurred", async () => {
|
|
669
|
+
const deploymentResults = await runDeploymentHistory();
|
|
670
|
+
const allEntries = diary.getByPartition("acme");
|
|
671
|
+
const history = generateOperationHistory(allEntries, deploymentResults);
|
|
672
|
+
|
|
673
|
+
// Deployments with conflicts should have conflict counts
|
|
674
|
+
const deploymentsWithConflicts = history.deployments.filter(
|
|
675
|
+
(d) => d.conflictCount > 0,
|
|
676
|
+
);
|
|
677
|
+
expect(deploymentsWithConflicts.length).toBeGreaterThan(0);
|
|
678
|
+
|
|
679
|
+
// The formatted text should show conflict markers
|
|
680
|
+
const text = history.formatted;
|
|
681
|
+
expect(text).toContain("conflict");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("handles an operation with zero deployments gracefully", () => {
|
|
685
|
+
const history = generateOperationHistory([], []);
|
|
686
|
+
|
|
687
|
+
expect(history.overview.totalDeployments).toBe(0);
|
|
688
|
+
expect(history.overview.successRate).toBe("N/A");
|
|
689
|
+
expect(history.deployments).toHaveLength(0);
|
|
690
|
+
expect(history.configurationPatterns).toHaveLength(0);
|
|
691
|
+
expect(history.environmentNotes).toHaveLength(0);
|
|
692
|
+
|
|
693
|
+
expect(history.formatted).toContain("Total deployments: 0");
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Postmortem structural guarantees
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
describe("Postmortem report — structural guarantees", () => {
|
|
702
|
+
let diary: DecisionDebrief;
|
|
703
|
+
let deployments: InMemoryDeploymentStore;
|
|
704
|
+
let healthChecker: MockHealthChecker;
|
|
705
|
+
let agent: SynthAgent;
|
|
706
|
+
let stores: TestStores;
|
|
707
|
+
|
|
708
|
+
beforeEach(() => {
|
|
709
|
+
diary = new DecisionDebrief();
|
|
710
|
+
deployments = new InMemoryDeploymentStore();
|
|
711
|
+
healthChecker = new MockHealthChecker();
|
|
712
|
+
const partStore = new PartitionStore();
|
|
713
|
+
const envStore = new EnvironmentStore();
|
|
714
|
+
const artStore = new ArtifactStore();
|
|
715
|
+
stores = { partitions: partStore, environments: envStore, artifacts: artStore };
|
|
716
|
+
agent = new SynthAgent(diary, deployments, artStore, envStore, partStore, healthChecker, {
|
|
717
|
+
healthCheckBackoffMs: 1,
|
|
718
|
+
executionDelayMs: 1,
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("timeline entries are sorted chronologically", async () => {
|
|
723
|
+
healthChecker.willReturn(CONN_REFUSED, HEALTHY);
|
|
724
|
+
|
|
725
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
726
|
+
|
|
727
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
728
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
729
|
+
|
|
730
|
+
for (let i = 1; i < postmortem.timeline.length; i++) {
|
|
731
|
+
expect(
|
|
732
|
+
postmortem.timeline[i].timestamp.getTime(),
|
|
733
|
+
).toBeGreaterThanOrEqual(
|
|
734
|
+
postmortem.timeline[i - 1].timestamp.getTime(),
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("configuration section accurately reflects variable and conflict counts", async () => {
|
|
740
|
+
const partition = makePartition({
|
|
741
|
+
variables: { LOG_LEVEL: "error", APP_ENV: "production" },
|
|
742
|
+
});
|
|
743
|
+
const env = makeEnvironment({
|
|
744
|
+
variables: { LOG_LEVEL: "warn", APP_ENV: "production", DB_POOL: "10" },
|
|
745
|
+
});
|
|
746
|
+
healthChecker.willReturn(HEALTHY);
|
|
747
|
+
|
|
748
|
+
const deployment = await testDeploy(agent, stores, { variables: { LOG_LEVEL: "debug" } }, partition, env);
|
|
749
|
+
|
|
750
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
751
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
752
|
+
|
|
753
|
+
// LOG_LEVEL has three-way conflict (env → partition → trigger), total vars = 3
|
|
754
|
+
expect(postmortem.configuration.variableCount).toBeGreaterThanOrEqual(3);
|
|
755
|
+
expect(postmortem.configuration.conflictCount).toBeGreaterThanOrEqual(1);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("failure analysis is null for successful deployments", async () => {
|
|
759
|
+
healthChecker.willReturn(HEALTHY);
|
|
760
|
+
|
|
761
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
762
|
+
|
|
763
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
764
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
765
|
+
|
|
766
|
+
expect(postmortem.failureAnalysis).toBeNull();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("failure analysis includes the failed step name", async () => {
|
|
770
|
+
healthChecker.willReturn(DNS_FAILURE);
|
|
771
|
+
|
|
772
|
+
const deployment = await testDeploy(agent, stores, {});
|
|
773
|
+
|
|
774
|
+
const entries = diary.getByDeployment(deployment.id);
|
|
775
|
+
const postmortem = generatePostmortem(entries, deployment);
|
|
776
|
+
|
|
777
|
+
expect(postmortem.failureAnalysis).not.toBeNull();
|
|
778
|
+
expect(postmortem.failureAnalysis!.failedStep).toBe(
|
|
779
|
+
"preflight-health-check",
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
});
|